@ricardochen1996/browser-use-mcp 0.0.0-stage → 0.1.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/lib/http.js ADDED
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Streamable HTTP: one endpoint, one browser per MCP session.
3
+ *
4
+ * Useful when several agents should call one long-running server instead of each launching its own
5
+ * stdio process. Each MCP session (its own `initialize`) still gets its own browser session — its own
6
+ * profile directory, its own DevTools connection, its own tab — so two agents never click on one
7
+ * page by accident. A session that stops talking for a while is closed, tab and browser included, so
8
+ * an abandoned client does not leave a Chrome running forever.
9
+ *
10
+ * The transport itself is the official SDK's, stateful mode; this module owns the routing, the
11
+ * per-session lifecycle, and nothing about the protocol.
12
+ *
13
+ * @module browser-use-mcp/http
14
+ */
15
+
16
+ import { randomUUID } from 'node:crypto'
17
+ import { createServer as createHttpServer } from 'node:http'
18
+ import { rmSync } from 'node:fs'
19
+ import { join, sep } from 'node:path'
20
+
21
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
22
+
23
+ import { createServer, createSession, validProfileName } from './server.js'
24
+
25
+ /**
26
+ * Serve the MCP protocol over Streamable HTTP until the process is signalled.
27
+ *
28
+ * @param {object} config - the resolved configuration.
29
+ * @param {object} logger - the stderr logger.
30
+ * @returns {Promise<{url: string, close: () => Promise<void>}>}
31
+ */
32
+ export async function serveHttp(config, logger) {
33
+ const entries = new Map()
34
+ const idleMs = config.httpIdleMs
35
+ let closing = false
36
+ let resolveClosed
37
+ const closed = new Promise(resolve => { resolveClosed = resolve })
38
+
39
+ const dispose = async (sessionId, reason) => {
40
+ const entry = entries.get(sessionId)
41
+ if (entry === undefined) return
42
+ entries.delete(sessionId)
43
+ logger?.info(`MCP session ${sessionId.slice(0, 8)} closed (${reason}); closing its browser`)
44
+ try {
45
+ await entry.server.close()
46
+ } catch (error) {
47
+ logger?.debug?.(`closing the MCP session failed: ${error?.message ?? error}`)
48
+ }
49
+ try {
50
+ const problem = await entry.session.shutdown()
51
+ if (problem !== '') logger?.error(`a browser was not shut down cleanly: ${problem}`)
52
+ } catch (error) {
53
+ logger?.error(`stopping a browser failed: ${error?.stack ?? error}`)
54
+ }
55
+ // An anonymous HTTP session is ephemeral: its client cannot come back to the same profile (the
56
+ // session id is random), so the directory is removed. A named profile is the opposite — it is
57
+ // kept, which is what makes its logins survive a reconnect.
58
+ const profileDir = entry.session.connection.userDataDir
59
+ const managed = join(config.browserUseMcpHome, 'profiles') + sep
60
+ if (entry.persistentProfile !== true && profileDir.startsWith(managed)) {
61
+ try {
62
+ rmSync(profileDir, { recursive: true, force: true })
63
+ } catch (error) {
64
+ logger?.debug?.(`removing ${profileDir} failed: ${error?.message ?? error}`)
65
+ }
66
+ }
67
+ }
68
+
69
+ const http = createHttpServer(async (request, response) => {
70
+ try {
71
+ const url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`)
72
+ if (url.pathname !== config.path) {
73
+ response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
74
+ response.end(`Not found. MCP is served at ${config.path}\n`)
75
+ return
76
+ }
77
+ const header = request.headers['mcp-session-id']
78
+ const knownId = Array.isArray(header) ? header[0] : header
79
+ if (knownId !== undefined && knownId !== '') {
80
+ const entry = entries.get(knownId)
81
+ if (entry === undefined) {
82
+ response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
83
+ response.end('Unknown or expired MCP session. Send initialize again.\n')
84
+ return
85
+ }
86
+ entry.lastSeen = Date.now()
87
+ // A long POST (a browser launch, a goal run) sends no further request while it runs; counting
88
+ // it in flight keeps the idle sweep from closing the session under its own answer. A GET is
89
+ // the long-lived server-to-client stream by design and must not count, or a client that keeps
90
+ // one open would never look idle.
91
+ const counted = request.method === 'POST'
92
+ if (counted) entry.inFlight += 1
93
+ try {
94
+ await entry.transport.handleRequest(request, response)
95
+ } finally {
96
+ if (counted) entry.inFlight -= 1
97
+ entry.lastSeen = Date.now()
98
+ }
99
+ return
100
+ }
101
+ if (request.method !== 'POST') {
102
+ response.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' })
103
+ response.end('Send an MCP initialize request with POST first.\n')
104
+ return
105
+ }
106
+ // A new MCP session: its own browser session, its own profile, its own sidecar. A client may
107
+ // name its profile so the directory — and its logins — survive reconnects: either
108
+ // `/mcp?profile=<name>` or an `X-Browser-Use-Profile: <name>` header on this initialize
109
+ // request. Without a name the profile is anonymous and removed when the session ends.
110
+ const requestedProfile = url.searchParams.get('profile')
111
+ ?? (typeof request.headers['x-browser-use-profile'] === 'string' ? request.headers['x-browser-use-profile'] : null)
112
+ if (requestedProfile !== null && !validProfileName(requestedProfile)) {
113
+ response.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' })
114
+ response.end('Invalid profile name: use 1-32 characters from [A-Za-z0-9_-] (?profile=<name> or X-Browser-Use-Profile).\n')
115
+ return
116
+ }
117
+ const sessionId = randomUUID()
118
+ const session = createSession(config, {
119
+ suffix: sessionId,
120
+ ...(requestedProfile === null ? {} : { profileName: requestedProfile }),
121
+ logger,
122
+ })
123
+ const server = createServer({ config, session, logger })
124
+ const transport = new StreamableHTTPServerTransport({
125
+ sessionIdGenerator: () => sessionId,
126
+ onsessioninitialized: initialized => {
127
+ entries.set(initialized, {
128
+ transport,
129
+ server,
130
+ session,
131
+ lastSeen: Date.now(),
132
+ inFlight: 0,
133
+ persistentProfile: requestedProfile !== null,
134
+ })
135
+ logger?.info(`MCP session ${initialized.slice(0, 8)} started (${entries.size} live)`)
136
+ },
137
+ onsessionclosed: initialized => {
138
+ void dispose(initialized, 'the client closed the session')
139
+ },
140
+ // Plain JSON answers keep a simple agent's HTTP client working; an SSE-capable client still
141
+ // gets its standalone stream on GET, which the transport serves itself.
142
+ enableJsonResponse: true,
143
+ })
144
+ transport.onclose = () => {
145
+ if (!closing) void dispose(sessionId, 'transport closed')
146
+ }
147
+ await server.connect(transport)
148
+ await transport.handleRequest(request, response)
149
+ } catch (error) {
150
+ logger?.error(`an HTTP request failed: ${error?.stack ?? error}`)
151
+ try {
152
+ if (!response.headersSent) {
153
+ response.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' })
154
+ }
155
+ response.end('Internal server error\n')
156
+ } catch {
157
+ // The connection is already gone; nothing left to answer.
158
+ }
159
+ }
160
+ })
161
+
162
+ http.on('clientError', (error, socket) => {
163
+ logger?.debug?.(`an HTTP client connection failed: ${error.message}`)
164
+ socket.destroy()
165
+ })
166
+
167
+ await new Promise((resolve, reject) => {
168
+ http.once('error', reject)
169
+ http.listen(config.port, config.host, () => {
170
+ http.off('error', reject)
171
+ resolve()
172
+ })
173
+ })
174
+
175
+ const shown = config.host === '0.0.0.0' || config.host === '::' ? '127.0.0.1' : config.host
176
+ const url = `http://${shown}:${config.port}${config.path}`
177
+ const tools = await import('./tools.js')
178
+ logger?.info(`serving MCP over Streamable HTTP at ${url}`)
179
+ const idleText = idleMs < 60_000 ? `${(idleMs / 1000).toFixed(idleMs % 1000 === 0 ? 0 : 1)} seconds` : `${Math.round(idleMs / 60_000)} minutes`
180
+ logger?.info(`${tools.toolNames(config).length} tools per session; each MCP session gets its own browser; idle sessions close after ${idleText}`)
181
+ if (config.host !== '127.0.0.1' && config.host !== '::1' && config.host !== 'localhost') {
182
+ logger?.error(
183
+ `the HTTP endpoint is bound to ${config.host}: it has no authentication. Use it only on a trusted `
184
+ + 'network, or bind --host 127.0.0.1 and reach it through a tunnel you control.',
185
+ )
186
+ }
187
+
188
+ // Sweep often enough that a short test timeout behaves, and never more than once a minute.
189
+ const sweepEvery = Math.min(60_000, Math.max(1_000, Math.floor(idleMs / 4)))
190
+ const sweep = setInterval(() => {
191
+ const now = Date.now()
192
+ for (const [sessionId, entry] of entries) {
193
+ if (entry.inFlight > 0) continue
194
+ if (now - entry.lastSeen > idleMs) void dispose(sessionId, 'idle timeout')
195
+ }
196
+ }, sweepEvery)
197
+ sweep.unref?.()
198
+
199
+ const shutdown = async reason => {
200
+ if (closing) return
201
+ closing = true
202
+ clearInterval(sweep)
203
+ logger?.info(`shutting down the HTTP endpoint (${reason})`)
204
+ // Stop accepting first, close every session's transport (which ends its SSE streams), and only
205
+ // then force the sockets shut: a client that never comes back must not keep the process alive
206
+ // after SIGTERM, which is exactly what an open event stream would otherwise do.
207
+ const closed = new Promise(resolve => http.close(() => resolve()))
208
+ await Promise.allSettled([...entries.keys()].map(sessionId => dispose(sessionId, reason)))
209
+ await Promise.race([closed, new Promise(resolve => setTimeout(resolve, 1_500))])
210
+ http.closeIdleConnections?.()
211
+ http.closeAllConnections?.()
212
+ await Promise.race([closed, new Promise(resolve => setTimeout(resolve, 1_500))])
213
+ resolveClosed()
214
+ }
215
+
216
+ process.once('SIGINT', () => { void shutdown('SIGINT') })
217
+ process.once('SIGTERM', () => { void shutdown('SIGTERM') })
218
+ process.once('SIGHUP', () => { void shutdown('SIGHUP') })
219
+
220
+ await closed
221
+ return { url, close: async () => shutdown('closed') }
222
+ }
package/lib/jev.js ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Jev: the policy behind `browser_goal` and `browser_act`'s intent, and where its endpoints come from.
3
+ *
4
+ * In `dsh-browser-use` these values are resolved from the harness configuration and inherited from
5
+ * the conversation's model route. An MCP server has no conversation to inherit from, so the values
6
+ * come from the server's own configuration (flags and environment), and are handed to the sidecar
7
+ * with each call that spends model quota — never left in the server's environment, where a goal run
8
+ * could read a stale key.
9
+ *
10
+ * @module browser-use-mcp/jev
11
+ */
12
+
13
+ /** Variables the engine reads for one model-calling request; nothing else may leak into a sidecar. */
14
+ export const ENGINE_VARIABLES = [
15
+ 'JEV_DECIDER',
16
+ 'TYPESAFE_API_KEY', 'TYPESAFE_BASE_URL', 'TYPESAFE_MODEL', 'TYPESAFE_FALLBACK_URL',
17
+ 'TEXT_MODEL_API_KEY', 'TEXT_MODEL_BASE_URL', 'TEXT_MODEL', 'TEXT_MODEL_REASONING', 'TEXT_MODEL_HEADERS',
18
+ ]
19
+
20
+ /**
21
+ * The engine environment for the next model-spending request, or `{}` when none is configured.
22
+ *
23
+ * With `jev-decider chat` the sidecar serves the engine's chat-model endpoint on loopback and points
24
+ * the engine's TypeSafe variables at it, so the TypeSafe values are deliberately not sent: the text
25
+ * model is the only model that run resolves.
26
+ * @param {object} config - the resolved configuration.
27
+ */
28
+ export function jevEnvironment(config) {
29
+ if (!config.jev.enabled) return {}
30
+ const env = {}
31
+ const set = (name, value) => {
32
+ if (value !== undefined && value !== null && String(value) !== '') env[name] = String(value)
33
+ }
34
+ if (config.jev.decider === 'chat') {
35
+ env.JEV_DECIDER = 'chat'
36
+ } else {
37
+ set('TYPESAFE_BASE_URL', config.jev.typesafe.baseURL)
38
+ set('TYPESAFE_MODEL', config.jev.typesafe.model)
39
+ set('TYPESAFE_FALLBACK_URL', config.jev.typesafe.fallbackURL)
40
+ set('TYPESAFE_API_KEY', config.jev.typesafe.apiKey)
41
+ }
42
+ set('TEXT_MODEL_API_KEY', config.jev.textModel.apiKey)
43
+ set('TEXT_MODEL_BASE_URL', config.jev.textModel.baseURL)
44
+ set('TEXT_MODEL', config.jev.textModel.model)
45
+ set('TEXT_MODEL_REASONING', config.jev.textModel.reasoning)
46
+ if (Object.keys(config.jev.textModel.headers).length > 0) {
47
+ set('TEXT_MODEL_HEADERS', JSON.stringify(config.jev.textModel.headers))
48
+ }
49
+ return env
50
+ }
51
+
52
+ /**
53
+ * Whether a policy call can run at all with this configuration, and what is missing if not.
54
+ *
55
+ * This is the question the tool asks *before* it reaches the engine, so a misconfigured server gets
56
+ * one sentence naming the variable to set instead of an engine stack trace. It is not a promise that
57
+ * a configured endpoint answers: only a real call can know that.
58
+ * @param {object} config - the resolved configuration.
59
+ * @returns {{ready: boolean, missing: string[], description: string}}
60
+ */
61
+ export function jevReadiness(config) {
62
+ if (!config.jev.enabled) {
63
+ return { ready: false, missing: [], description: 'Jev is off (--no-jev): browser_goal and browser_act intents are not offered.' }
64
+ }
65
+ if (config.jev.decider === 'chat') {
66
+ const missing = []
67
+ if (config.jev.textModel.baseURL === '') missing.push('TEXT_MODEL_BASE_URL')
68
+ if (config.jev.textModel.model === '') missing.push('TEXT_MODEL')
69
+ return {
70
+ ready: missing.length === 0,
71
+ missing,
72
+ description: missing.length === 0
73
+ ? `the text model ${config.jev.textModel.model} at ${config.jev.textModel.baseURL} decides (--jev-decider chat)`
74
+ : `--jev-decider chat needs ${missing.join(' and ')} so the text model can decide`,
75
+ }
76
+ }
77
+ const missing = []
78
+ if (config.jev.typesafe.apiKey === '') missing.push('TYPESAFE_API_KEY')
79
+ // The text helper writes a TYPE_TEXT value; a goal with only clicks needs no text model, so this
80
+ // is reported but does not make the policy unusable.
81
+ const textNote = config.jev.textModel.apiKey === '' && config.jev.textModel.baseURL === ''
82
+ ? '; TYPE_TEXT values need TEXT_MODEL_API_KEY/TEXT_MODEL_BASE_URL/TEXT_MODEL, or write them yourself with browser_act'
83
+ : ''
84
+ return {
85
+ ready: missing.length === 0,
86
+ missing,
87
+ description: missing.length === 0
88
+ ? `Jev at ${config.jev.typesafe.baseURL || 'the default TypeSafe endpoint'} (model ${config.jev.typesafe.model || 'default'}) decides${textNote}`
89
+ : `${missing.join(' and ')} is required for browser_goal and browser_act intents, or pass --jev-decider chat with TEXT_MODEL_*`,
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Where the Jev credentials come from, in one line the doctor can print — never a key itself.
95
+ * @param {object} config - the resolved configuration.
96
+ * @returns {string}
97
+ */
98
+ export function jevDescribe(config) {
99
+ if (!config.jev.enabled) return 'off (--no-jev)'
100
+ const key = value => (String(value ?? '').trim() === '' ? 'unset' : 'set')
101
+ if (config.jev.decider === 'chat') {
102
+ return `on, chat decider: text model ${config.jev.textModel.model || '(unset)'} at ${config.jev.textModel.baseURL || '(unset)'}, key ${key(config.jev.textModel.apiKey)}`
103
+ }
104
+ return `on, typesafe decider: ${config.jev.typesafe.baseURL || 'default endpoint'} (model ${config.jev.typesafe.model || 'default'}), key ${key(config.jev.typesafe.apiKey)}; text model ${config.jev.textModel.model || '(unset)'} at ${config.jev.textModel.baseURL || '(unset)'}, key ${key(config.jev.textModel.apiKey)}`
105
+ }
package/lib/log.js ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Logging for a protocol whose stdout belongs to the transport.
3
+ *
4
+ * An MCP stdio server's stdout carries JSON-RPC frames, so every log line here goes to stderr.
5
+ * A line that landed on stdout would corrupt the protocol rather than merely spoil the output.
6
+ *
7
+ * @module browser-use-mcp/log
8
+ */
9
+
10
+ const LEVELS = { silent: 0, error: 1, info: 2, debug: 3 }
11
+ const PREFIX = 'browser-use-mcp'
12
+
13
+ /** One stderr logger bound to a level; `debug` is silent unless the level asks for it. */
14
+ export function createLogger(level = 'info') {
15
+ const threshold = LEVELS[level] ?? LEVELS.info
16
+ const write = (kind, args) => {
17
+ if (threshold < LEVELS[kind]) return
18
+ const text = args
19
+ .map(value => (value instanceof Error ? (value.stack ?? value.message) : typeof value === 'string' ? value : JSON.stringify(value)))
20
+ .join(' ')
21
+ process.stderr.write(`${PREFIX} ${text}\n`)
22
+ }
23
+ return {
24
+ level,
25
+ error: (...args) => write('error', args),
26
+ info: (...args) => write('info', args),
27
+ debug: (...args) => write('debug', args),
28
+ }
29
+ }
30
+
31
+ /** A logger that throws everything away, for library callers and tests. */
32
+ export const silentLogger = createLogger('silent')