@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/CHANGELOG.md +165 -0
- package/LICENSE +27 -0
- package/NOTICE +34 -0
- package/README.md +452 -2
- package/README.zh-CN.md +328 -0
- package/bin/browser-use-mcp.mjs +83 -0
- package/docs/architecture.md +210 -0
- package/docs/clients.md +425 -0
- package/docs/config.md +260 -0
- package/lib/cli.js +146 -0
- package/lib/config.js +341 -0
- package/lib/doctor.js +62 -0
- package/lib/engine.js +717 -0
- package/lib/http.js +222 -0
- package/lib/jev.js +105 -0
- package/lib/log.js +32 -0
- package/lib/provision.js +585 -0
- package/lib/python.js +470 -0
- package/lib/render.js +75 -0
- package/lib/server.js +193 -0
- package/lib/session.js +484 -0
- package/lib/sidecar.js +472 -0
- package/lib/tools.js +352 -0
- package/package.json +69 -4
- package/sidecar/bridge.py +982 -0
- package/vendor/jev-ultrafast.json +6 -0
- package/vendor/jev_ultrafast-0.1.1-py3-none-any.whl +0 -0
- package/vendor/requirements.txt +74 -0
package/lib/config.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One resolved configuration: command-line flags over environment variables over defaults.
|
|
3
|
+
*
|
|
4
|
+
* Nothing else in the server reads `process.env` for configuration, and nothing reads a flag. This is
|
|
5
|
+
* the one place that knows both, so the doctor and a browser call can never disagree about what the
|
|
6
|
+
* server was told.
|
|
7
|
+
*
|
|
8
|
+
* @module browser-use-mcp/config
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { homedir } from 'node:os'
|
|
12
|
+
import { join, resolve } from 'node:path'
|
|
13
|
+
|
|
14
|
+
/** A command line or environment value this server cannot use, with the value that fixes it. */
|
|
15
|
+
export class ConfigError extends Error {
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message)
|
|
18
|
+
this.name = 'ConfigError'
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The documented defaults, which are also what the doctor reports for an unset field. */
|
|
23
|
+
export const DEFAULTS = {
|
|
24
|
+
transport: 'stdio',
|
|
25
|
+
host: '127.0.0.1',
|
|
26
|
+
port: 7331,
|
|
27
|
+
path: '/mcp',
|
|
28
|
+
// The point of this server is usually "use the browser I already use": driving the Chrome that is
|
|
29
|
+
// running costs no new profile and no new logins. `--mode launch` switches to a dedicated browser.
|
|
30
|
+
mode: 'attach',
|
|
31
|
+
/**
|
|
32
|
+
* When several MCP sessions drive the browser you already use, they each get their own tab and
|
|
33
|
+
* their own CDP connection. `exclusive` refuses the second session instead (and is the safer
|
|
34
|
+
* choice when two agents must never touch the same browser); `shared` is the default because the
|
|
35
|
+
* common case is one server serving several agents against one Chrome.
|
|
36
|
+
*/
|
|
37
|
+
attachSharing: 'shared',
|
|
38
|
+
cdpEndpoint: '',
|
|
39
|
+
executablePath: '',
|
|
40
|
+
profile: 'default',
|
|
41
|
+
userDataDir: '',
|
|
42
|
+
headless: false,
|
|
43
|
+
browserUseMcpHome: join(homedir(), '.browser-use-mcp'),
|
|
44
|
+
pythonPath: '',
|
|
45
|
+
allowEval: true,
|
|
46
|
+
allowScreenshots: true,
|
|
47
|
+
jev: {
|
|
48
|
+
enabled: true,
|
|
49
|
+
decider: 'typesafe',
|
|
50
|
+
typesafe: { baseURL: '', model: '', fallbackURL: '', apiKey: '' },
|
|
51
|
+
textModel: { baseURL: '', model: '', reasoning: 'thinking-disabled', headers: {}, apiKey: '' },
|
|
52
|
+
},
|
|
53
|
+
requestTimeoutMs: 180_000,
|
|
54
|
+
// An HTTP MCP session silent for this long has its browser closed; a closed browser must not keep
|
|
55
|
+
// a profile, a process, and a CDP connection for an agent that is never coming back.
|
|
56
|
+
httpIdleMs: 15 * 60_000,
|
|
57
|
+
logLevel: 'info',
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const TRANSPORTS = ['stdio', 'http']
|
|
61
|
+
const MODES = ['launch', 'attach']
|
|
62
|
+
const DECIDERS = ['typesafe', 'chat']
|
|
63
|
+
const REASONING = ['', 'none', 'thinking-disabled']
|
|
64
|
+
const LOG_LEVELS = ['silent', 'error', 'info', 'debug']
|
|
65
|
+
/** A profile name becomes a directory name, so only this shape can be one. */
|
|
66
|
+
const PROFILE_NAME = /^[A-Za-z0-9._-]{1,64}$/u
|
|
67
|
+
const HTTP_URL = /^(?:https?|wss?):\/\/\S+$/u
|
|
68
|
+
|
|
69
|
+
/** `~` and `~/…` expand to the home directory; everything else is returned trimmed. */
|
|
70
|
+
export function expandHome(value) {
|
|
71
|
+
if (value === '~') return homedir()
|
|
72
|
+
if (value.startsWith('~/')) return join(homedir(), value.slice(1))
|
|
73
|
+
return value
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** One environment variable as a trimmed string, or undefined when it is unset or blank. */
|
|
77
|
+
function envValue(env, name) {
|
|
78
|
+
const value = env[name]
|
|
79
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** An environment switch: `1/true/yes/on` is true, `0/false/no/off` is false, anything else is refused. */
|
|
83
|
+
export function envBoolean(env, name) {
|
|
84
|
+
const raw = envValue(env, name)?.toLowerCase()
|
|
85
|
+
if (raw === undefined) return undefined
|
|
86
|
+
if (['1', 'true', 'yes', 'on'].includes(raw)) return true
|
|
87
|
+
if (['0', 'false', 'no', 'off'].includes(raw)) return false
|
|
88
|
+
throw new ConfigError(`${name} must be a boolean (1/true/yes/on or 0/false/no/off), not ${JSON.stringify(env[name])}`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The first value that is not undefined, so a flag of `undefined` never shadows the environment. */
|
|
92
|
+
function pick(...values) {
|
|
93
|
+
return values.find(value => value !== undefined)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A whole number in `[min, max]`, or the fallback when the value is unset. */
|
|
97
|
+
function integer(value, label, fallback, min, max) {
|
|
98
|
+
if (value === undefined) return fallback
|
|
99
|
+
const number = Number(value)
|
|
100
|
+
if (!Number.isInteger(number) || number < min || number > max) {
|
|
101
|
+
throw new ConfigError(`${label} must be an integer between ${min} and ${max}, not ${JSON.stringify(value)}`)
|
|
102
|
+
}
|
|
103
|
+
return number
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** One enum value, case-insensitive, with the accepted set in the error. */
|
|
107
|
+
function oneOf(value, label, accepted) {
|
|
108
|
+
if (value === undefined) return undefined
|
|
109
|
+
const normalized = String(value).trim().toLowerCase()
|
|
110
|
+
if (!accepted.includes(normalized)) {
|
|
111
|
+
throw new ConfigError(`${label} must be one of ${accepted.map(item => JSON.stringify(item)).join(', ')}, not ${JSON.stringify(value)}`)
|
|
112
|
+
}
|
|
113
|
+
return normalized
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** An http(s)/ws(s) URL, or the empty string. */
|
|
117
|
+
function endpoint(value, label) {
|
|
118
|
+
if (value === undefined || value === '') return ''
|
|
119
|
+
const trimmed = String(value).trim()
|
|
120
|
+
if (!HTTP_URL.test(trimmed)) throw new ConfigError(`${label} must be an http(s) or ws(s) URL without whitespace, not ${JSON.stringify(value)}`)
|
|
121
|
+
return trimmed
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A path setting: home expanded, relative paths resolved against the working directory. */
|
|
125
|
+
function pathSetting(value, cwd) {
|
|
126
|
+
if (value === undefined || value === '') return ''
|
|
127
|
+
return resolve(cwd, expandHome(String(value).trim()))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** JSON object of string values, as `TEXT_MODEL_HEADERS` carries request headers. */
|
|
131
|
+
function headerObject(value) {
|
|
132
|
+
if (value === undefined || value === '') return {}
|
|
133
|
+
let parsed
|
|
134
|
+
try {
|
|
135
|
+
parsed = JSON.parse(value)
|
|
136
|
+
} catch (error) {
|
|
137
|
+
throw new ConfigError(`TEXT_MODEL_HEADERS must be a JSON object: ${error.message}`)
|
|
138
|
+
}
|
|
139
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)
|
|
140
|
+
|| Object.values(parsed).some(item => typeof item !== 'string')) {
|
|
141
|
+
throw new ConfigError('TEXT_MODEL_HEADERS must be a JSON object whose values are strings')
|
|
142
|
+
}
|
|
143
|
+
return { ...parsed }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Resolve one command line and environment into the configuration every other module reads.
|
|
148
|
+
*
|
|
149
|
+
* @param {object} input
|
|
150
|
+
* @param {string} [input.command] - `serve`, `doctor` or `install`.
|
|
151
|
+
* @param {object} [input.flags] - the parsed command-line flags from `./cli.js`.
|
|
152
|
+
* @param {NodeJS.ProcessEnv} [input.env] - the environment to read (default `process.env`).
|
|
153
|
+
* @param {string} [input.cwd] - working directory for relative paths.
|
|
154
|
+
* @returns {object} the resolved configuration.
|
|
155
|
+
* @throws {ConfigError} with the field that has to change.
|
|
156
|
+
*/
|
|
157
|
+
export function resolveConfig({ command = 'serve', flags = {}, env = process.env, cwd = process.cwd() } = {}) {
|
|
158
|
+
const transport = oneOf(pick(flags.transport, envValue(env, 'BROWSER_USE_MCP_TRANSPORT'), DEFAULTS.transport), 'transport', TRANSPORTS)
|
|
159
|
+
const host = String(pick(flags.host, envValue(env, 'BROWSER_USE_MCP_HOST'), DEFAULTS.host)).trim()
|
|
160
|
+
if (host === '') throw new ConfigError('host must not be empty')
|
|
161
|
+
const port = integer(pick(flags.port, envValue(env, 'BROWSER_USE_MCP_PORT')), 'port', DEFAULTS.port, 1, 65_535)
|
|
162
|
+
let route = String(pick(flags.path, envValue(env, 'BROWSER_USE_MCP_PATH'), DEFAULTS.path)).trim()
|
|
163
|
+
if (!route.startsWith('/')) route = `/${route}`
|
|
164
|
+
if (/\s/u.test(route)) throw new ConfigError(`path must not contain whitespace, not ${JSON.stringify(route)}`)
|
|
165
|
+
|
|
166
|
+
const cdpEndpoint = endpoint(pick(flags.cdpEndpoint, envValue(env, 'BROWSER_USE_MCP_CDP_ENDPOINT'), ''), 'cdpEndpoint')
|
|
167
|
+
const explicitMode = oneOf(pick(flags.mode, envValue(env, 'BROWSER_USE_MCP_MODE')), 'mode', MODES)
|
|
168
|
+
// A configured endpoint names a running browser, which is what attach means; saying both
|
|
169
|
+
// "launch" and an endpoint would silently ignore the mode, so only the consistent pair is accepted.
|
|
170
|
+
if (explicitMode === 'launch' && cdpEndpoint !== '') {
|
|
171
|
+
throw new ConfigError('cdpEndpoint belongs to attach mode; drop one of --mode launch / --cdp-endpoint')
|
|
172
|
+
}
|
|
173
|
+
const mode = explicitMode ?? (cdpEndpoint !== '' ? 'attach' : DEFAULTS.mode)
|
|
174
|
+
|
|
175
|
+
const explicitSharing = oneOf(
|
|
176
|
+
pick(flags.attachSharing, envValue(env, 'BROWSER_USE_MCP_ATTACH_SHARING')),
|
|
177
|
+
'attach-sharing',
|
|
178
|
+
['exclusive', 'shared'],
|
|
179
|
+
)
|
|
180
|
+
if (mode === 'launch' && explicitSharing === 'shared') {
|
|
181
|
+
throw new ConfigError('attachSharing "shared" needs attach mode; a launched browser already belongs to one session')
|
|
182
|
+
}
|
|
183
|
+
// Launch mode has nothing to share, so the resolved value is always exclusive there. This keeps
|
|
184
|
+
// the doctor from advertising a setting that does nothing.
|
|
185
|
+
const attachSharing = mode === 'attach' ? (explicitSharing ?? DEFAULTS.attachSharing) : 'exclusive'
|
|
186
|
+
|
|
187
|
+
const executablePath = pathSetting(pick(flags.executablePath, envValue(env, 'BROWSER_USE_MCP_EXECUTABLE_PATH')), cwd)
|
|
188
|
+
const profile = String(pick(flags.profile, envValue(env, 'BROWSER_USE_MCP_PROFILE'), DEFAULTS.profile)).trim()
|
|
189
|
+
if (!PROFILE_NAME.test(profile) || profile === '.' || profile === '..') {
|
|
190
|
+
throw new ConfigError(`profile must match [A-Za-z0-9._-]{1,64} and not be a relative path, not ${JSON.stringify(profile)}`)
|
|
191
|
+
}
|
|
192
|
+
const browserUseMcpHome = pathSetting(
|
|
193
|
+
pick(flags.browserUseMcpHome, envValue(env, 'BROWSER_USE_MCP_HOME'), DEFAULTS.browserUseMcpHome),
|
|
194
|
+
cwd,
|
|
195
|
+
)
|
|
196
|
+
const userDataDir = pathSetting(pick(flags.userDataDir, envValue(env, 'BROWSER_USE_MCP_USER_DATA_DIR')), cwd)
|
|
197
|
+
|| join(browserUseMcpHome, 'profiles', profile)
|
|
198
|
+
const headless = pick(flags.headless, envBoolean(env, 'BROWSER_USE_MCP_HEADLESS'), DEFAULTS.headless)
|
|
199
|
+
if (typeof headless !== 'boolean') throw new ConfigError('headless must be true or false')
|
|
200
|
+
|
|
201
|
+
const pythonPath = pathSetting(pick(flags.pythonPath, envValue(env, 'BROWSER_USE_MCP_PYTHON')), cwd)
|
|
202
|
+
|
|
203
|
+
// `--no-eval` (or BROWSER_USE_MCP_NO_EVAL=1) withdraws the tool; BROWSER_USE_MCP_EVAL=0 is the
|
|
204
|
+
// same answer said positively, so either spelling means the same thing.
|
|
205
|
+
const allowEval = flags.noEval !== true
|
|
206
|
+
&& envBoolean(env, 'BROWSER_USE_MCP_NO_EVAL') !== true
|
|
207
|
+
&& envBoolean(env, 'BROWSER_USE_MCP_EVAL') !== false
|
|
208
|
+
const allowScreenshots = flags.noScreenshots !== true
|
|
209
|
+
&& envBoolean(env, 'BROWSER_USE_MCP_NO_SCREENSHOTS') !== true
|
|
210
|
+
&& envBoolean(env, 'BROWSER_USE_MCP_SCREENSHOTS') !== false
|
|
211
|
+
|
|
212
|
+
const jevOff = flags.noJev === true
|
|
213
|
+
|| envBoolean(env, 'BROWSER_USE_MCP_NO_JEV') === true
|
|
214
|
+
|| ['off', 'false', '0', 'no'].includes(String(envValue(env, 'BROWSER_USE_MCP_JEV') ?? '').toLowerCase())
|
|
215
|
+
const decider = oneOf(pick(flags.jevDecider, envValue(env, 'JEV_DECIDER'), DEFAULTS.jev.decider), 'jev-decider', DECIDERS)
|
|
216
|
+
const reasoning = oneOf(
|
|
217
|
+
pick(flags.textModelReasoning, envValue(env, 'TEXT_MODEL_REASONING'), DEFAULTS.jev.textModel.reasoning),
|
|
218
|
+
'text-model-reasoning',
|
|
219
|
+
REASONING,
|
|
220
|
+
)
|
|
221
|
+
const jev = {
|
|
222
|
+
enabled: !jevOff,
|
|
223
|
+
decider,
|
|
224
|
+
typesafe: {
|
|
225
|
+
baseURL: endpoint(pick(flags.typesafeBaseURL, envValue(env, 'TYPESAFE_BASE_URL'), ''), 'typesafe-base-url'),
|
|
226
|
+
model: String(pick(flags.typesafeModel, envValue(env, 'TYPESAFE_MODEL'), '')).trim(),
|
|
227
|
+
fallbackURL: endpoint(envValue(env, 'TYPESAFE_FALLBACK_URL') ?? '', 'TYPESAFE_FALLBACK_URL'),
|
|
228
|
+
apiKey: String(pick(flags.typesafeApiKey, envValue(env, 'TYPESAFE_API_KEY'), '')).trim(),
|
|
229
|
+
},
|
|
230
|
+
textModel: {
|
|
231
|
+
baseURL: endpoint(pick(flags.textModelBaseURL, envValue(env, 'TEXT_MODEL_BASE_URL'), ''), 'text-model-base-url'),
|
|
232
|
+
model: String(pick(flags.textModel, envValue(env, 'TEXT_MODEL'), '')).trim(),
|
|
233
|
+
reasoning,
|
|
234
|
+
headers: headerObject(envValue(env, 'TEXT_MODEL_HEADERS')),
|
|
235
|
+
apiKey: String(pick(flags.textModelApiKey, envValue(env, 'TEXT_MODEL_API_KEY'), '')).trim(),
|
|
236
|
+
},
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const requestTimeoutMs = integer(
|
|
240
|
+
pick(flags.requestTimeoutMs, envValue(env, 'BROWSER_USE_MCP_REQUEST_TIMEOUT_MS')),
|
|
241
|
+
'request-timeout-ms',
|
|
242
|
+
DEFAULTS.requestTimeoutMs,
|
|
243
|
+
1_000,
|
|
244
|
+
3_600_000,
|
|
245
|
+
)
|
|
246
|
+
const httpIdleMs = integer(
|
|
247
|
+
envValue(env, 'BROWSER_USE_MCP_HTTP_IDLE_MS'),
|
|
248
|
+
'BROWSER_USE_MCP_HTTP_IDLE_MS',
|
|
249
|
+
DEFAULTS.httpIdleMs,
|
|
250
|
+
1_000,
|
|
251
|
+
24 * 60_000,
|
|
252
|
+
)
|
|
253
|
+
const logLevel = oneOf(pick(flags.logLevel, envValue(env, 'BROWSER_USE_MCP_LOG_LEVEL'), DEFAULTS.logLevel), 'log-level', LOG_LEVELS)
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
command,
|
|
257
|
+
transport,
|
|
258
|
+
host,
|
|
259
|
+
port,
|
|
260
|
+
path: route,
|
|
261
|
+
mode,
|
|
262
|
+
attachSharing,
|
|
263
|
+
cdpEndpoint,
|
|
264
|
+
executablePath,
|
|
265
|
+
profile,
|
|
266
|
+
userDataDir,
|
|
267
|
+
headless,
|
|
268
|
+
browserUseMcpHome,
|
|
269
|
+
pythonPath,
|
|
270
|
+
allowEval,
|
|
271
|
+
allowScreenshots,
|
|
272
|
+
jev,
|
|
273
|
+
requestTimeoutMs,
|
|
274
|
+
httpIdleMs,
|
|
275
|
+
logLevel,
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The connection parameters one request that may start a browser carries.
|
|
281
|
+
*
|
|
282
|
+
* A call that does not start a browser still sends them: the sidecar reads them only when it has to
|
|
283
|
+
* connect, so the same object is correct for every request.
|
|
284
|
+
* @param {object} config - the resolved configuration.
|
|
285
|
+
* @param {object} [overrides] - a profile directory or headless switch for one session (HTTP mode).
|
|
286
|
+
*/
|
|
287
|
+
export function connectionFor(config, overrides = {}) {
|
|
288
|
+
const userDataDir = overrides.userDataDir ?? config.userDataDir
|
|
289
|
+
const headless = overrides.headless ?? config.headless
|
|
290
|
+
return {
|
|
291
|
+
mode: config.mode,
|
|
292
|
+
...(config.cdpEndpoint === '' ? {} : { cdpEndpoint: config.cdpEndpoint }),
|
|
293
|
+
...(config.executablePath === '' ? {} : { executablePath: config.executablePath }),
|
|
294
|
+
userDataDir,
|
|
295
|
+
...(headless ? { headless: true } : {}),
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Whether a value is set, without ever printing the value: the doctor's only safe key answer. */
|
|
300
|
+
export function presence(value) {
|
|
301
|
+
return value === undefined || value === null || String(value).trim() === '' ? 'unset' : 'set'
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The configuration as the doctor reads it: what is in use, never a secret.
|
|
306
|
+
* @param {object} config - the resolved configuration.
|
|
307
|
+
* @returns {string[]} one line per setting group.
|
|
308
|
+
*/
|
|
309
|
+
export function describeConfig(config) {
|
|
310
|
+
const lines = []
|
|
311
|
+
lines.push(
|
|
312
|
+
config.transport === 'stdio'
|
|
313
|
+
? 'Transport: stdio (MCP over stdin/stdout)'
|
|
314
|
+
: `Transport: Streamable HTTP http://${config.host}:${config.port}${config.path}`,
|
|
315
|
+
)
|
|
316
|
+
lines.push(
|
|
317
|
+
config.mode === 'attach'
|
|
318
|
+
? `Connection: attach to ${config.cdpEndpoint || 'the Chrome you browse with (found through chrome://inspect remote debugging)'}`
|
|
319
|
+
+ `, attach sharing ${config.attachSharing}`
|
|
320
|
+
: `Connection: launch ${config.executablePath || 'Chrome/Chromium'}, profile "${config.profile}" at ${config.userDataDir}${config.headless ? ', headless' : ''}`
|
|
321
|
+
+ (config.transport === 'http' ? ' (named profiles persist; unnamed sessions get an ephemeral profile)' : ''),
|
|
322
|
+
)
|
|
323
|
+
if (config.jev.enabled) {
|
|
324
|
+
const who = config.jev.decider === 'chat'
|
|
325
|
+
? `decided by the text model at ${config.jev.textModel.baseURL || '(engine default endpoint)'} (model ${config.jev.textModel.model || '(engine default)'}, key ${presence(config.jev.textModel.apiKey)})`
|
|
326
|
+
: `decided by Jev at ${config.jev.typesafe.baseURL || '(default endpoint)'} (model ${config.jev.typesafe.model || '(default)'}, key ${presence(config.jev.typesafe.apiKey)})`
|
|
327
|
+
lines.push(`Jev : enabled, ${who}; browser_goal and browser_act intents spend model quota`)
|
|
328
|
+
} else {
|
|
329
|
+
lines.push('Jev : off (browser_goal and browser_act intents are not offered)')
|
|
330
|
+
}
|
|
331
|
+
lines.push(`State : ${config.browserUseMcpHome}${config.pythonPath ? ` (interpreter ${config.pythonPath})` : ' (managed Python environment)'}`)
|
|
332
|
+
lines.push(`Timeouts: ${Math.round(config.requestTimeoutMs / 1000)}s per browser request`)
|
|
333
|
+
if (config.transport === 'http') {
|
|
334
|
+
const idle = config.httpIdleMs < 60_000
|
|
335
|
+
? `${(config.httpIdleMs / 1000).toFixed(config.httpIdleMs % 1000 === 0 ? 0 : 1)} seconds`
|
|
336
|
+
: `${Math.round(config.httpIdleMs / 60_000)} minutes`
|
|
337
|
+
lines.push(`HTTP : idle MCP sessions are closed after ${idle}`)
|
|
338
|
+
}
|
|
339
|
+
lines.push(`Logging : ${config.logLevel} (stderr)`)
|
|
340
|
+
return lines
|
|
341
|
+
}
|
package/lib/doctor.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The doctor: everything a person should know before the first browser call, in one report.
|
|
3
|
+
*
|
|
4
|
+
* It only looks — `install` is a separate command — so calling it never builds an environment or
|
|
5
|
+
* opens a page. The same function backs `browser_doctor` (so an agent can diagnose itself) and
|
|
6
|
+
* `browser-use-mcp doctor` (so a terminal can, before any MCP client is configured).
|
|
7
|
+
*
|
|
8
|
+
* @module browser-use-mcp/doctor
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describeConfig } from './config.js'
|
|
12
|
+
import { inspectEngine, reportText } from './engine.js'
|
|
13
|
+
import { jevDescribe, jevReadiness } from './jev.js'
|
|
14
|
+
|
|
15
|
+
/** One line about the live session: what it has open, or that it is idle. */
|
|
16
|
+
function sessionLine(session) {
|
|
17
|
+
if (!session) return 'idle (this transport has no browser session yet)'
|
|
18
|
+
const status = session.status()
|
|
19
|
+
if (status.page) {
|
|
20
|
+
return `page ${status.page.title ? `${status.page.title} — ` : ''}${status.page.url} (observation ${status.page.observation})`
|
|
21
|
+
+ `${status.lastAction ? `, last action: ${status.lastAction}` : ''}${status.sidecarRunning ? '' : ', browser not running'}`
|
|
22
|
+
}
|
|
23
|
+
if (status.problem) return `no page open; the last browser was not shut down cleanly (${status.problem})`
|
|
24
|
+
return 'idle (no page has been opened by this session)'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The full self-check report.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} options
|
|
31
|
+
* @param {object} options.config - the resolved configuration.
|
|
32
|
+
* @param {import('./session.js').BrowserSession} [options.session] - the live session, if one exists.
|
|
33
|
+
* @param {string[]} [options.tools] - the tool names this configuration offers.
|
|
34
|
+
* @param {string} [options.version] - this package's version.
|
|
35
|
+
* @param {boolean} [options.fresh] - probe again instead of reusing the remembered engine answer.
|
|
36
|
+
* @returns {Promise<{text: string, ok: boolean, report: object}>} the report and whether it is ready.
|
|
37
|
+
*/
|
|
38
|
+
export async function doctorReport({ config, session, tools = [], version = '', fresh = false } = {}) {
|
|
39
|
+
const report = await inspectEngine(config, { fresh })
|
|
40
|
+
const lines = []
|
|
41
|
+
lines.push(`browser-use-mcp${version ? ` v${version}` : ''} — self-check (Node ${process.version}, ${process.platform}-${process.arch})`)
|
|
42
|
+
lines.push('')
|
|
43
|
+
// The configuration lines, except the Jev one: the doctor can say more than "on/off" — whether a
|
|
44
|
+
// model call could actually run — and one subject deserves one line.
|
|
45
|
+
for (const line of describeConfig(config)) {
|
|
46
|
+
if (!line.startsWith('Jev')) lines.push(line)
|
|
47
|
+
}
|
|
48
|
+
const readiness = jevReadiness(config)
|
|
49
|
+
lines.push(`Jev : ${jevDescribe(config)}${readiness.ready ? '' : ` — NOT READY: ${readiness.description}`}`)
|
|
50
|
+
lines.push(`Tools : ${tools.length === 0 ? '(none)' : tools.join(', ')}`)
|
|
51
|
+
lines.push(`Session : ${sessionLine(session)}`)
|
|
52
|
+
lines.push('')
|
|
53
|
+
lines.push(reportText(report, {}))
|
|
54
|
+
return { text: lines.join('\n'), ok: report.ok, report }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The doctor report as the CLI prints it; the same text, with the problems it found. */
|
|
58
|
+
export async function doctorFromCli({ config, version, log }) {
|
|
59
|
+
const result = await doctorReport({ config, version, fresh: true })
|
|
60
|
+
log?.info?.(`checking the browser engine at ${config.browserUseMcpHome}`)
|
|
61
|
+
return result
|
|
62
|
+
}
|