aether-browser 0.1.0-rc.1 → 0.2.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.
package/README.md CHANGED
@@ -105,7 +105,7 @@ server on a Linux host and drive it from macOS, Windows, or CI.
105
105
 
106
106
  ## Security
107
107
 
108
- The v0.1 noVNC surface is **unauthenticated** and intended for numeric loopback on a machine you
108
+ The v0.x noVNC surface is **unauthenticated** and intended for numeric loopback on a machine you
109
109
  control. Treat every process and user that can reach that loopback interface as trusted with the
110
110
  live browser view. Do not expose it through a tunnel, reverse proxy, or container bridge. See the
111
111
  [security model](https://github.com/AetherAI3/agent-browser/blob/main/docs/SECURITY-MODEL.md).
@@ -117,8 +117,8 @@ rejects unknown fields.
117
117
 
118
118
  ## Status
119
119
 
120
- `0.1.0-rc.1` tracks Agent Browser `v0.1.0-rc.1` and its `api_version: "v1"` contract. This is a
121
- release candidate: the API may change before `v0.1.0`. Issues and design discussion are welcome on
120
+ `0.2.0` tracks Agent Browser `v0.2.0` and its `api_version: "v1"` contract, and is released
121
+ version for version with the PyPI client. Issues and design discussion are welcome on
122
122
  [the repository](https://github.com/AetherAI3/agent-browser/issues).
123
123
 
124
124
  Apache-2.0 · [Aether AI](https://github.com/AetherAI3)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aether-browser",
3
- "version": "0.1.0-rc.1",
3
+ "version": "0.2.0",
4
4
  "description": "Client and CLI for Agent Browser by Aether AI: drive one self-hosted Chrome session over a closed HTTP API and watch or take over that same session through noVNC.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Aether AI",
@@ -47,7 +47,7 @@
47
47
  "computer-use"
48
48
  ],
49
49
  "scripts": {
50
- "test": "node --test test/client.test.js",
50
+ "test": "node --test test/client.test.js test/mcp.test.js",
51
51
  "typecheck": "tsc --noEmit -p tsconfig.json"
52
52
  },
53
53
  "devDependencies": {
package/src/cli.js CHANGED
@@ -254,6 +254,13 @@ function cmdOpen() {
254
254
  return 0
255
255
  }
256
256
 
257
+ async function cmdMcp() {
258
+ // stdout belongs to the JSON-RPC transport from here on; never print to it.
259
+ const { runMcpServer } = await import('./mcp.js')
260
+ runMcpServer()
261
+ return new Promise(() => {})
262
+ }
263
+
257
264
  function usage() {
258
265
  console.log(`
259
266
  ${bold('aether-browser')} ${dim(PKG.version)}
@@ -268,6 +275,7 @@ ${bold('Commands')}
268
275
  down Stop the runtime and remove its volumes
269
276
  status Print the server health document as JSON
270
277
  open Open the live noVNC view in a browser
278
+ mcp Serve this session to an MCP client over stdio (Claude Code, Cursor, ...)
271
279
  help Show this message
272
280
 
273
281
  ${bold('Environment')}
@@ -275,6 +283,10 @@ ${bold('Environment')}
275
283
  AGENT_BROWSER_CONTROLLER_TOKEN Controller token, if the server runs authenticated
276
284
  AGENT_BROWSER_OBSERVER_TOKEN Observer token
277
285
 
286
+ ${bold('MCP')}
287
+ Add to your MCP client config:
288
+ {"mcpServers":{"agent-browser":{"command":"npx","args":["-y","aether-browser","mcp"]}}}
289
+
278
290
  ${bold('Library')}
279
291
  import { AgentBrowser, withSession } from 'aether-browser'
280
292
 
@@ -290,6 +302,7 @@ const commands = {
290
302
  down: cmdDown,
291
303
  status: cmdStatus,
292
304
  open: cmdOpen,
305
+ mcp: cmdMcp,
293
306
  help: usage,
294
307
  '--help': usage,
295
308
  '-h': usage,
package/src/index.js CHANGED
@@ -75,8 +75,14 @@ function compact(object) {
75
75
  return out
76
76
  }
77
77
 
78
+ // Trailing slashes are trimmed by scanning rather than with /\/+$/, which backtracks
79
+ // polynomially on a long run of slashes. The base URL is normally the developer's own
80
+ // config, but a linear scan costs nothing and removes the failure mode outright.
78
81
  function normalizeBaseUrl(value) {
79
- return String(value).replace(/\/+$/, '')
82
+ const text = String(value)
83
+ let end = text.length
84
+ while (end > 0 && text.charCodeAt(end - 1) === 0x2f) end -= 1
85
+ return text.slice(0, end)
80
86
  }
81
87
 
82
88
  /**
package/src/mcp.js ADDED
@@ -0,0 +1,406 @@
1
+ /**
2
+ * Model Context Protocol server for Agent Browser.
3
+ *
4
+ * Exposes one Agent Browser session as MCP tools over stdio, so any MCP client
5
+ * (Claude Code, Claude Desktop, Cursor, Windsurf, or your own) can drive the same
6
+ * headed Chrome session a human is watching over noVNC.
7
+ *
8
+ * The transport is newline-delimited JSON-RPC 2.0 on stdin/stdout, implemented
9
+ * here directly: this package has no runtime dependencies and the MCP server
10
+ * keeps that property.
11
+ *
12
+ * The server owns the session id so the model never has to carry it. Every
13
+ * response that can carry the live view URL does, because the point of this
14
+ * project is that a human can see and take over what the agent is doing.
15
+ */
16
+
17
+ import { readFileSync } from 'node:fs'
18
+ import { dirname, join } from 'node:path'
19
+ import { fileURLToPath } from 'node:url'
20
+
21
+ import { AgentBrowser, AgentBrowserError, ALLOWED_KEYS, DEFAULT_BASE_URL } from './index.js'
22
+
23
+ const SERVER_NAME = 'agent-browser'
24
+
25
+ /**
26
+ * The version reported to MCP clients is the package's own, read at load time rather than
27
+ * repeated here, so a release bump cannot leave this server advertising a stale version.
28
+ */
29
+ const SERVER_VERSION = (() => {
30
+ try {
31
+ const here = dirname(fileURLToPath(import.meta.url))
32
+ return JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')).version
33
+ } catch {
34
+ return '0.0.0'
35
+ }
36
+ })()
37
+ const SUPPORTED_PROTOCOLS = new Set(['2024-11-05', '2025-03-26', '2025-06-18'])
38
+ const FALLBACK_PROTOCOL = '2025-06-18'
39
+
40
+ const str = (description) => ({ type: 'string', description })
41
+ const int = (description) => ({ type: 'integer', description })
42
+
43
+ const TOOLS = [
44
+ {
45
+ name: 'browser_open',
46
+ description:
47
+ 'Start the one headed Chrome session and return the live view URL. Give that URL to the ' +
48
+ 'human: they can watch this exact session, and take over in it, while you drive. Safe to ' +
49
+ 'call twice — it reuses the session that is already open.',
50
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
51
+ },
52
+ {
53
+ name: 'browser_navigate',
54
+ description:
55
+ 'Navigate the session to an http(s) URL and return the page title, final URL and readable ' +
56
+ 'text. The server validates the destination and refuses blocked address classes.',
57
+ inputSchema: {
58
+ type: 'object',
59
+ properties: { url: str('Absolute http(s) URL to open.') },
60
+ required: ['url'],
61
+ additionalProperties: false,
62
+ },
63
+ },
64
+ {
65
+ name: 'browser_read',
66
+ description:
67
+ 'Read the current page: title, URL, readable text and a bounded accessibility tree. Prefer ' +
68
+ 'this over a screenshot — it is structure, not pixels, and it does not spend a vision step ' +
69
+ 'unless you ask for the image.',
70
+ inputSchema: {
71
+ type: 'object',
72
+ properties: {
73
+ include_screenshot: {
74
+ type: 'boolean',
75
+ description:
76
+ 'Also return the PNG as base64. Costs one vision step from the session budget. ' +
77
+ 'Default false.',
78
+ },
79
+ },
80
+ additionalProperties: false,
81
+ },
82
+ },
83
+ {
84
+ name: 'browser_click',
85
+ description: 'Click a CSS selector, or an x/y point in the viewport. Provide one, not both.',
86
+ inputSchema: {
87
+ type: 'object',
88
+ properties: {
89
+ selector: str('CSS selector to click.'),
90
+ x: int('Viewport x coordinate.'),
91
+ y: int('Viewport y coordinate.'),
92
+ },
93
+ additionalProperties: false,
94
+ },
95
+ },
96
+ {
97
+ name: 'browser_type',
98
+ description:
99
+ 'Type text into a CSS selector, or at an x/y point. Text is sent byte for byte. Do not use ' +
100
+ 'this for a secret a human should enter — ask them to take over in the live view instead.',
101
+ inputSchema: {
102
+ type: 'object',
103
+ properties: {
104
+ text: str('Text to type.'),
105
+ selector: str('CSS selector to type into.'),
106
+ x: int('Viewport x coordinate.'),
107
+ y: int('Viewport y coordinate.'),
108
+ },
109
+ required: ['text'],
110
+ additionalProperties: false,
111
+ },
112
+ },
113
+ {
114
+ name: 'browser_press',
115
+ description: `Press one allowlisted key or combination. Allowed: ${ALLOWED_KEYS.join(', ')}.`,
116
+ inputSchema: {
117
+ type: 'object',
118
+ properties: { key: { type: 'string', enum: [...ALLOWED_KEYS], description: 'Key to press.' } },
119
+ required: ['key'],
120
+ additionalProperties: false,
121
+ },
122
+ },
123
+ {
124
+ name: 'browser_scroll',
125
+ description: 'Scroll the page by a nonzero pixel delta.',
126
+ inputSchema: {
127
+ type: 'object',
128
+ properties: {
129
+ delta_y: int('Vertical pixels. Positive scrolls down.'),
130
+ delta_x: int('Horizontal pixels. Positive scrolls right.'),
131
+ },
132
+ additionalProperties: false,
133
+ },
134
+ },
135
+ {
136
+ name: 'browser_status',
137
+ description:
138
+ 'Report whether the runtime is up, whether a session is open, how many vision steps remain, ' +
139
+ 'and the live view URL to hand to a human.',
140
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
141
+ },
142
+ {
143
+ name: 'browser_close',
144
+ description:
145
+ 'End the session and release the browser. Idempotent. Call this when the task is finished so ' +
146
+ 'the single session slot is free.',
147
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
148
+ },
149
+ ]
150
+
151
+ class Server {
152
+ constructor(browser, { out = process.stdout, log = process.stderr } = {}) {
153
+ this.browser = browser
154
+ this.session = null
155
+ this.out = out
156
+ this.log = log
157
+ }
158
+
159
+ note(message) {
160
+ // stdout is the protocol channel; anything human-readable goes to stderr.
161
+ this.log.write(`[agent-browser mcp] ${message}\n`)
162
+ }
163
+
164
+ send(message) {
165
+ this.out.write(`${JSON.stringify(message)}\n`)
166
+ }
167
+
168
+ reply(id, result) {
169
+ if (id !== undefined && id !== null) this.send({ jsonrpc: '2.0', id, result })
170
+ }
171
+
172
+ fail(id, code, message) {
173
+ if (id !== undefined && id !== null) this.send({ jsonrpc: '2.0', id, error: { code, message } })
174
+ }
175
+
176
+ text(value) {
177
+ return { content: [{ type: 'text', text: value }] }
178
+ }
179
+
180
+ errorText(value) {
181
+ return { content: [{ type: 'text', text: value }], isError: true }
182
+ }
183
+
184
+ async ensureSession() {
185
+ if (this.session && !this.session.ended) return this.session
186
+ this.session = await this.browser.createSession()
187
+ return this.session
188
+ }
189
+
190
+ requireSession() {
191
+ if (!this.session || this.session.ended) {
192
+ throw new AgentBrowserError(
193
+ 'No session is open. Call browser_open first.',
194
+ 'SESSION_NOT_FOUND',
195
+ )
196
+ }
197
+ return this.session
198
+ }
199
+
200
+ view() {
201
+ return this.session && !this.session.ended ? this.session.viewUrl : null
202
+ }
203
+
204
+ withView(lines) {
205
+ const url = this.view()
206
+ return url ? `${lines}\n\nLive view (a human can watch or take over here): ${url}` : lines
207
+ }
208
+
209
+ async handleTool(name, args) {
210
+ const a = args ?? {}
211
+ switch (name) {
212
+ case 'browser_open': {
213
+ const reused = Boolean(this.session && !this.session.ended)
214
+ const session = await this.ensureSession()
215
+ return this.text(
216
+ `${reused ? 'Reusing the open session' : 'Session open'}: ${session.id}\n` +
217
+ `Vision steps available: ${session.maxVisionSteps}\n` +
218
+ `Expires: ${session.expiresAt}\n\n` +
219
+ `Live view (a human can watch or take over here): ${session.viewUrl}\n` +
220
+ 'Anything they do in that window happens in this same session.',
221
+ )
222
+ }
223
+ case 'browser_navigate': {
224
+ const session = await this.ensureSession()
225
+ const r = await session.navigate(String(a.url))
226
+ return this.text(
227
+ this.withView(
228
+ `Navigated to ${r.final_url}\nTitle: ${r.title}\n\n${trim(r.readable_text, 4000)}`,
229
+ ),
230
+ )
231
+ }
232
+ case 'browser_read': {
233
+ const session = this.requireSession()
234
+ const wantImage = a.include_screenshot === true
235
+ const r = await session.snapshot()
236
+ const nodes = (r.accessibility?.nodes ?? [])
237
+ .slice(0, 60)
238
+ .map((n) => ` ${n.role}${n.name ? ` "${n.name}"` : ''}${n.disabled ? ' [disabled]' : ''}`)
239
+ .join('\n')
240
+ const body =
241
+ `URL: ${r.url}\nTitle: ${r.title}\n` +
242
+ `Vision steps remaining: ${r.vision_steps_remaining}\n\n` +
243
+ `Readable text:\n${trim(r.readable_text, 6000)}\n\n` +
244
+ `Accessibility (first ${Math.min(60, r.accessibility?.nodes?.length ?? 0)} nodes):\n${nodes}`
245
+ const content = [{ type: 'text', text: this.withView(body) }]
246
+ if (wantImage && r.screenshot_base64) {
247
+ content.push({ type: 'image', data: r.screenshot_base64, mimeType: 'image/png' })
248
+ }
249
+ return { content }
250
+ }
251
+ case 'browser_click': {
252
+ const session = this.requireSession()
253
+ await session.click(pick(a, ['selector', 'x', 'y']))
254
+ return this.text(this.withView(`Clicked ${describeTarget(a)}.`))
255
+ }
256
+ case 'browser_type': {
257
+ const session = this.requireSession()
258
+ await session.type({ text: String(a.text), ...pick(a, ['selector', 'x', 'y']) })
259
+ return this.text(this.withView(`Typed ${String(a.text).length} characters into ${describeTarget(a)}.`))
260
+ }
261
+ case 'browser_press': {
262
+ const session = this.requireSession()
263
+ await session.press(a.key)
264
+ return this.text(this.withView(`Pressed ${a.key}.`))
265
+ }
266
+ case 'browser_scroll': {
267
+ const session = this.requireSession()
268
+ await session.scroll({ deltaX: a.delta_x, deltaY: a.delta_y ?? (a.delta_x ? undefined : 600) })
269
+ return this.text(this.withView('Scrolled.'))
270
+ }
271
+ case 'browser_status': {
272
+ const h = await this.browser.health()
273
+ const url = this.view()
274
+ return this.text(
275
+ `Runtime: ${h.status} (v${h.version})\n` +
276
+ `Browser ready: ${h.browser_ready}\n` +
277
+ `Session active: ${h.session_active}\n` +
278
+ `Free session slots: ${h.slots_available}\n` +
279
+ (url ? `\nLive view: ${url}` : '\nNo session open. Call browser_open.'),
280
+ )
281
+ }
282
+ case 'browser_close': {
283
+ if (!this.session || this.session.ended) return this.text('No session was open.')
284
+ const r = await this.session.end()
285
+ this.session = null
286
+ return this.text(`Session ${r.status}. The browser slot is free.`)
287
+ }
288
+ default:
289
+ return this.errorText(`Unknown tool: ${name}`)
290
+ }
291
+ }
292
+
293
+ async dispatch(message) {
294
+ const { id, method, params } = message
295
+ switch (method) {
296
+ case 'initialize': {
297
+ const asked = params?.protocolVersion
298
+ return this.reply(id, {
299
+ protocolVersion: SUPPORTED_PROTOCOLS.has(asked) ? asked : FALLBACK_PROTOCOL,
300
+ capabilities: { tools: { listChanged: false } },
301
+ serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
302
+ instructions:
303
+ 'Agent Browser drives one headed Chrome session that a human can watch and take over ' +
304
+ 'over noVNC. Call browser_open first and show the returned live view URL to the user. ' +
305
+ 'When you hit something you should not do on your own — a login, a payment, a 2FA ' +
306
+ 'prompt, anything ambiguous — stop and ask the user to take over in that view rather ' +
307
+ 'than guessing. The session stays yours; they hand it straight back.',
308
+ })
309
+ }
310
+ case 'notifications/initialized':
311
+ case 'notifications/cancelled':
312
+ return
313
+ case 'ping':
314
+ return this.reply(id, {})
315
+ case 'tools/list':
316
+ return this.reply(id, { tools: TOOLS })
317
+ case 'tools/call': {
318
+ const name = params?.name
319
+ try {
320
+ return this.reply(id, await this.handleTool(name, params?.arguments))
321
+ } catch (error) {
322
+ const detail =
323
+ error instanceof AgentBrowserError
324
+ ? `${error.code ?? 'ERROR'}: ${error.message}`
325
+ : String(error?.message ?? error)
326
+ const hint =
327
+ error?.code === 'SESSION_CAPACITY_REACHED'
328
+ ? '\n\nThe runtime holds one browser session and something else already owns it — ' +
329
+ 'another client, or an earlier run that did not call browser_close. This server ' +
330
+ 'cannot adopt a session it did not create. Free the slot on the runtime, then ' +
331
+ 'call browser_open again.'
332
+ : error?.code === 'DESTINATION_BLOCKED'
333
+ ? '\n\nThe navigation policy refused that destination. It is not a bug: loopback, ' +
334
+ 'private and reserved address ranges are blocked by design.'
335
+ : error?.cause?.code === 'ECONNREFUSED'
336
+ ? `\n\nNothing is listening on ${this.browser.baseUrl}. Start the runtime with ` +
337
+ '`docker compose up --build`, or set AGENT_BROWSER_URL.'
338
+ : ''
339
+ return this.reply(id, this.errorText(`${detail}${hint}`))
340
+ }
341
+ }
342
+ case 'resources/list':
343
+ return this.reply(id, { resources: [] })
344
+ case 'prompts/list':
345
+ return this.reply(id, { prompts: [] })
346
+ default:
347
+ return this.fail(id, -32601, `Method not found: ${method}`)
348
+ }
349
+ }
350
+
351
+ listen(input = process.stdin) {
352
+ let buffer = ''
353
+ input.setEncoding('utf8')
354
+ input.on('data', (chunk) => {
355
+ buffer += chunk
356
+ let index
357
+ while ((index = buffer.indexOf('\n')) !== -1) {
358
+ const line = buffer.slice(0, index).trim()
359
+ buffer = buffer.slice(index + 1)
360
+ if (!line) continue
361
+ let message
362
+ try {
363
+ message = JSON.parse(line)
364
+ } catch {
365
+ this.note('ignored a line that was not JSON')
366
+ continue
367
+ }
368
+ Promise.resolve(this.dispatch(message)).catch((error) => {
369
+ this.note(`dispatch failed: ${error?.message ?? error}`)
370
+ this.fail(message?.id, -32603, String(error?.message ?? error))
371
+ })
372
+ }
373
+ })
374
+ input.on('end', () => process.exit(0))
375
+ }
376
+ }
377
+
378
+ function pick(source, keys) {
379
+ const out = {}
380
+ for (const key of keys) if (source[key] !== undefined) out[key] = source[key]
381
+ return out
382
+ }
383
+
384
+ function describeTarget(a) {
385
+ if (a.selector) return `\`${a.selector}\``
386
+ if (a.x !== undefined && a.y !== undefined) return `(${a.x}, ${a.y})`
387
+ return 'the focused element'
388
+ }
389
+
390
+ function trim(value, limit) {
391
+ const text = String(value ?? '')
392
+ return text.length <= limit ? text : `${text.slice(0, limit)}\n… (${text.length - limit} more characters)`
393
+ }
394
+
395
+ export function runMcpServer(options = {}) {
396
+ const browser = new AgentBrowser({
397
+ baseUrl: options.baseUrl ?? process.env.AGENT_BROWSER_URL ?? DEFAULT_BASE_URL,
398
+ timeoutMs: 120_000,
399
+ })
400
+ const server = new Server(browser)
401
+ server.note(`serving MCP over stdio against ${browser.baseUrl}`)
402
+ server.listen()
403
+ return server
404
+ }
405
+
406
+ export { TOOLS, Server }