aether-browser 0.1.0 → 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 +2 -2
- package/package.json +2 -2
- package/src/cli.js +13 -0
- package/src/mcp.js +406 -0
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.
|
|
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,7 +117,7 @@ rejects unknown fields.
|
|
|
117
117
|
|
|
118
118
|
## Status
|
|
119
119
|
|
|
120
|
-
`0.
|
|
120
|
+
`0.2.0` tracks Agent Browser `v0.2.0` and its `api_version: "v1"` contract, and is released
|
|
121
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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aether-browser",
|
|
3
|
-
"version": "0.
|
|
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/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 }
|