@diegosouzacdv/jev-browser-mcp 0.6.2 → 0.7.1

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.
@@ -7,33 +7,74 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio";
7
7
  import { chromium } from "playwright";
8
8
  import * as z from "zod/v4";
9
9
  import packageManifest from "../../../package.json" with { type: "json" };
10
- import { loadSettings, JevBrowserError } from "./config.mjs";
11
- import { chooseNextAction, createMutationAuthorizationStore, executeBrowserFlow } from "./flow.mjs";
12
-
13
- const browserPools = new Set();
10
+ import { loadSettings, JevBrowserError } from "./config.mjs";
11
+ import { chooseNextAction, collectFlowContractIssues, createMutationAuthorizationStore, describeBrowserActions, executeBrowserFlow } from "./flow.mjs";
12
+
13
+ const browserContract = describeBrowserActions();
14
+ const jsonObject = z.record(z.string(), z.unknown());
15
+ const browserOptionsShape = Object.fromEntries(browserContract.options.map((key) => {
16
+ const value = key === "return_snapshot" ? z.enum(["diff", "full", "none"])
17
+ : key === "capture_network" ? z.enum(["none", "errors", "all"])
18
+ : key === "reuse_page_match" ? z.enum(["url", "path"])
19
+ : key === "color_scheme" ? z.enum(["light", "dark", "no-preference"])
20
+ : key === "permissions" ? z.array(z.enum(["microphone", "geolocation"]))
21
+ : key === "console_levels" ? z.array(z.enum(["log", "info", "debug", "warn", "error"]))
22
+ : key === "ready" ? z.union([z.string(), z.record(z.string(), z.unknown()), z.array(z.union([z.string(), z.record(z.string(), z.unknown())]))])
23
+ : ["allow_mutations", "block_trackers", "capture_console_errors", "capture_network_error_bodies", "capture_network_errors", "auto_angular_idle", "block_fonts", "continue_from_current_page", "dry_run", "fast_path", "local_only", "ready_network_idle", "reuse_page", "screenshot_on_failure", "screenshot_on_success", "record_video", "mobile", "snapshot_include_hidden", "include_unnamed_controls", "stop_on_expected", "trace_on_failure", "trace_on_success", "fresh_context", "clear_storage"].includes(key) ? z.boolean()
24
+ : ["max_flow_steps", "ready_stable_ms", "ready_timeout_seconds", "step_timeout_seconds"].includes(key) ? z.number().positive()
25
+ : ["busy_selectors", "login_text", "login_url_contains"].includes(key) ? z.array(z.string())
26
+ : ["viewport", "geolocation", "wait_for_http", "fake_media"].includes(key) ? z.union([z.string(), jsonObject])
27
+ : z.string();
28
+ return [key, value.optional()];
29
+ }));
30
+ const browserOptionsSchema = z.object(browserOptionsShape).passthrough();
31
+ const candidatePlanSchema = z.object({
32
+ description: z.string().optional(),
33
+ steps: z.array(z.record(z.string(), z.unknown())).optional(),
34
+ comment: z.string().optional(),
35
+ when: z.unknown().optional(),
36
+ }).strict();
37
+ const expectedOutcomeSchema = z.union([
38
+ z.string(),
39
+ z.object({
40
+ text: z.string().optional(),
41
+ request: z.string().optional(),
42
+ method: z.string().optional(),
43
+ status: z.number().int().min(100).max(599).optional(),
44
+ message_contains: z.string().optional(),
45
+ }).strict().refine((value) => Boolean(value.text || value.request), "expected_outcome needs text or request"),
46
+ ]);
47
+
48
+ const browserPools = new Set();
14
49
 
15
50
  class BrowserPool {
16
51
  #settings;
17
52
  #contextPromise;
18
53
  #context;
19
- #browser;
54
+ #browser;
55
+ #extraContexts = new Set();
56
+ #extraBrowsers = new Set();
57
+ #contextBrowsers = new Map();
20
58
  #page;
21
59
  #startedAt;
22
- #everStarted = false;
23
- #navigationError;
60
+ #everStarted = false;
61
+ #navigationError;
62
+ #unrestoredPage = false;
24
63
  #flowQueue = Promise.resolve();
25
64
  #configurationKey;
65
+ #lastConfiguration = {};
26
66
 
27
67
  constructor(settings) {
28
68
  this.#settings = settings;
29
69
  }
30
70
 
31
- getNavigationError() {
32
- return this.#navigationError;
33
- }
34
-
35
- setNavigationError(error) {
36
- this.#navigationError = error || undefined;
71
+ getNavigationError() {
72
+ return this.#unrestoredPage ? "BROWSER_PAGE_NOT_RESTORED" : this.#navigationError;
73
+ }
74
+
75
+ setNavigationError(error) {
76
+ this.#navigationError = error || undefined;
77
+ this.#unrestoredPage = error === "BROWSER_PAGE_NOT_RESTORED";
37
78
  }
38
79
 
39
80
  async runFlow(work) {
@@ -46,9 +87,12 @@ class BrowserPool {
46
87
  } finally {
47
88
  release();
48
89
  }
49
- }
50
-
51
- async getPage(configuration = {}) {
90
+ }
91
+
92
+ async getPage(configuration) {
93
+ const hadStartedBeforeRequest = this.#everStarted;
94
+ if (configuration === undefined) configuration = this.#lastConfiguration;
95
+ else this.#lastConfiguration = configuration;
52
96
  const configurationKey = JSON.stringify(configuration);
53
97
  if (this.#contextPromise && this.#configurationKey !== configurationKey) await this.close();
54
98
  let isOpening = !this.#contextPromise;
@@ -75,7 +119,12 @@ class BrowserPool {
75
119
  throw error;
76
120
  }
77
121
  }
78
- return { page: this.#page, context: this.#context, sessionMs: isOpening ? performance.now() - this.#startedAt : 0 };
122
+ return {
123
+ page: this.#page,
124
+ context: this.#context,
125
+ sessionMs: isOpening ? performance.now() - this.#startedAt : 0,
126
+ recovered: isOpening && hadStartedBeforeRequest,
127
+ };
79
128
  }
80
129
 
81
130
  #invalidate(reason) {
@@ -102,7 +151,7 @@ class BrowserPool {
102
151
  await this.close();
103
152
  this.#everStarted = true;
104
153
  this.#startedAt = performance.now();
105
- this.#contextPromise = this.#open();
154
+ this.#contextPromise = this.#open(this.#lastConfiguration);
106
155
  try {
107
156
  await this.#contextPromise;
108
157
  return { status: "connected", connected: true, recovered: true, restarted: true, current_url: this.#page.url() };
@@ -115,6 +164,44 @@ class BrowserPool {
115
164
  if (this.#page !== page || !this.#context) return;
116
165
  this.#page = this.#context.pages()[0] || await this.#context.newPage();
117
166
  }
167
+
168
+ async createIsolatedContext(contextOptions = {}, launchArgs = []) {
169
+ let browser = this.#browser;
170
+ let ownsBrowser = false;
171
+ if (!browser) {
172
+ browser = await chromium.launch({
173
+ channel: this.#settings.browser.channel,
174
+ headless: this.#settings.browser.mode !== "computer",
175
+ args: launchArgs,
176
+ });
177
+ ownsBrowser = true;
178
+ }
179
+ try {
180
+ const context = await browser.newContext(contextOptions);
181
+ this.#extraContexts.add(context);
182
+ this.#contextBrowsers.set(context, { browser, ownsBrowser });
183
+ context.once("close", () => this.#extraContexts.delete(context));
184
+ if (ownsBrowser) {
185
+ this.#extraBrowsers.add(browser);
186
+ browser.once("disconnected", () => this.#extraBrowsers.delete(browser));
187
+ }
188
+ return context;
189
+ } catch (error) {
190
+ if (ownsBrowser) await browser.close().catch(() => {});
191
+ throw error;
192
+ }
193
+ }
194
+
195
+ async releaseContext(context) {
196
+ const ownership = this.#contextBrowsers.get(context);
197
+ await context?.close().catch(() => {});
198
+ this.#extraContexts.delete(context);
199
+ this.#contextBrowsers.delete(context);
200
+ if (ownership?.ownsBrowser) {
201
+ await ownership.browser.close().catch(() => {});
202
+ this.#extraBrowsers.delete(ownership.browser);
203
+ }
204
+ }
118
205
 
119
206
  #watchContext(context, browser) {
120
207
  context.once("close", () => {
@@ -125,8 +212,9 @@ class BrowserPool {
125
212
  });
126
213
  }
127
214
 
128
- async #open({ contextOptions = {}, launchArgs = [] } = {}) {
129
- const { mode, channel, profileDir } = this.#settings.browser;
215
+ async #open({ contextOptions = {}, launchArgs = [], profileDir: configuredProfileDir } = {}) {
216
+ const { mode, channel } = this.#settings.browser;
217
+ const profileDir = configuredProfileDir || this.#settings.browser.profileDir;
130
218
  try {
131
219
  if (mode === "computer") {
132
220
  this.#context = await chromium.launchPersistentContext(profileDir, {
@@ -156,7 +244,12 @@ class BrowserPool {
156
244
  }
157
245
  }
158
246
 
159
- async close() {
247
+ async close() {
248
+ await Promise.all([...this.#extraContexts].map((context) => this.releaseContext(context)));
249
+ await Promise.all([...this.#extraBrowsers].map((browser) => browser.close().catch(() => {})));
250
+ this.#extraContexts.clear();
251
+ this.#extraBrowsers.clear();
252
+ this.#contextBrowsers.clear();
160
253
  if (this.#context) await this.#context.close().catch(() => {});
161
254
  if (this.#browser) await this.#browser.close().catch(() => {});
162
255
  this.#context = undefined;
@@ -165,17 +258,47 @@ class BrowserPool {
165
258
  this.#contextPromise = undefined;
166
259
  this.#navigationError = undefined;
167
260
  this.#configurationKey = undefined;
261
+ this.#unrestoredPage = false;
168
262
  }
169
263
  }
170
264
 
171
- function toolError(error, settings) {
172
- let message = error instanceof JevBrowserError ? error.message : error?.message || `Jev browser flow failed (${error?.name || "Error"})`;
173
- const credential = settings.env?.[settings.jev.credentialEnv];
174
- if (credential) message = message.split(credential).join("[redacted]");
175
- message = message.replace(/\bBearer\s+\S+/gi, "Bearer [redacted]")
176
- .replace(/([?&](?:token|api[_-]?key|password|secret)=)[^&\s]+/gi, "$1[redacted]");
177
- return { isError: true, content: [{ type: "text", text: `[jev-browser ${packageManifest.version}] ${message}` }] };
178
- }
265
+ function safeErrorMessage(error, settings) {
266
+ let message = error instanceof JevBrowserError ? error.message : error?.message || `Jev browser flow failed (${error?.name || "Error"})`;
267
+ const credential = settings.env?.[settings.jev.credentialEnv];
268
+ if (credential) message = message.split(credential).join("[redacted]");
269
+ message = message.replace(/\bBearer\s+\S+/gi, "Bearer [redacted]")
270
+ .replace(/([?&](?:token|api[_-]?key|password|secret)=)[^&\s]+/gi, "$1[redacted]");
271
+ return message;
272
+ }
273
+
274
+ function toolError(error, settings) {
275
+ const message = safeErrorMessage(error, settings);
276
+ return { isError: true, content: [{ type: "text", text: `[jev-browser ${packageManifest.version}] ${message}` }] };
277
+ }
278
+
279
+ function isBrowserDisconnectError(error) {
280
+ return /(?:target page, context or browser has been closed|browser has been closed|browser disconnected|context has been closed|connection closed|connection is closed)/i
281
+ .test(String(error?.message || error?.name || error || ""));
282
+ }
283
+
284
+ function isBrowserStartupError(error) {
285
+ return /(?:could not start the configured .* browser|browser profile is in use by PID \d+)/i
286
+ .test(String(error?.message || error?.name || error || ""));
287
+ }
288
+
289
+ function browserEnvironmentError(reason, navigationError, extra = {}) {
290
+ return {
291
+ server_version: packageManifest.version,
292
+ status: "environment_error",
293
+ navigation_error: navigationError,
294
+ reason,
295
+ acceptance_criteria_met: false,
296
+ expected_outcome_visible: false,
297
+ steps_executed: 0,
298
+ steps: [],
299
+ ...extra,
300
+ };
301
+ }
179
302
 
180
303
  export function fitToolResponse(value, maxBytes) {
181
304
  const clone = structuredClone(value && typeof value === "object" ? value : { result: value });
@@ -290,11 +413,33 @@ export function createJevBrowserServer({ env = process.env, configPath, fetchImp
290
413
  description: "Check the managed browser connection; set restart=true to close and restart only the browser session owned by this MCP process.",
291
414
  inputSchema: { restart: z.boolean().optional() },
292
415
  },
293
- async ({ restart = false } = {}) => toolText(
294
- restart ? await browserPool.restart() : await browserPool.health(),
295
- settings.browser.maxToolResponseBytes,
296
- ),
297
- );
416
+ async ({ restart = false } = {}) => {
417
+ const health = restart ? await browserPool.restart() : await browserPool.health();
418
+ return toolText({
419
+ ...health,
420
+ server_version: packageManifest.version,
421
+ browser_mode: settings.browser.mode,
422
+ session_id: settings.browser.sessionId,
423
+ capabilities: {
424
+ actions: Object.keys(describeBrowserActions().actions),
425
+ options: describeBrowserActions().options,
426
+ media_permissions: ["microphone", "geolocation"],
427
+ fake_media: "WAV audio input",
428
+ storage_state: "named account snapshots",
429
+ contexts: ["current", "new isolated context"],
430
+ },
431
+ }, settings.browser.maxToolResponseBytes);
432
+ },
433
+ );
434
+
435
+ server.registerTool(
436
+ "describe_actions",
437
+ {
438
+ description: "Return the live JSON contract for every supported Jev Browser MCP action, option, alias, timeout and placeholder rule.",
439
+ inputSchema: {},
440
+ },
441
+ async () => toolText({ server_version: packageManifest.version, ...describeBrowserActions() }, settings.browser.maxToolResponseBytes),
442
+ );
298
443
 
299
444
  server.registerTool(
300
445
  "choose_next_action",
@@ -320,42 +465,98 @@ export function createJevBrowserServer({ env = process.env, configPath, fetchImp
320
465
  },
321
466
  );
322
467
 
323
- server.registerTool(
324
- "run_browser_flow",
325
- {
326
- description: "Run a bounded screen flow in a warm Playwright session. Continues the current same-origin page by default without repeating navigation. Supports role and label locators, exact row scopes, guarded dialog confirmation, network assertions, variables and extraction, AngularJS idle waits, field-value/enabled waits, dry runs before mutating steps, per-step timing and screenshots, Markdown/JUnit reports, uploads, downloads, accessibility audits, and clear navigation diagnostics. Jev chooses among supplied plans; one plan skips the decision call when fast_path is enabled.",
327
- inputSchema: {
328
- flow: z.string(),
329
- initial_url: z.string(),
330
- expected_outcome: z.string(),
331
- candidate_plans: z.record(z.string(), z.unknown()),
332
- params: z.record(z.string(), z.unknown()).optional(),
333
- options: z.record(z.string(), z.unknown()).optional(),
334
- },
335
- },
336
- async ({ flow, initial_url: initialUrl, expected_outcome: expectedOutcome, candidate_plans: candidatePlans, params, options }) => {
337
- try {
468
+ server.registerTool(
469
+ "run_browser_flow",
470
+ {
471
+ description: "Run a bounded screen flow in an isolated per-server browser profile. Continues the current SPA page by default. Supports media fakes, tabs and contexts, read-only page inspection, conditional plans, HTTP/network/WebSocket assertions, named storage state, guarded mutation confirmation, per-step timeouts and evidence. expected_outcome accepts descriptive text or structured text/request assertions. Use describe_actions for required and optional fields and the exact options contract. Jev chooses among eligible supplied plans; a single plan skips the decision call when fast_path is enabled.",
472
+ inputSchema: {
473
+ flow: z.string().optional(),
474
+ initial_url: z.string().optional(),
475
+ expected_outcome: expectedOutcomeSchema.optional(),
476
+ candidate_plans: z.record(z.string(), candidatePlanSchema).optional(),
477
+ params: z.record(z.string(), z.unknown()).optional(),
478
+ options: browserOptionsSchema.optional(),
479
+ },
480
+ },
481
+ async ({ flow, initial_url: initialUrl, expected_outcome: expectedOutcome, candidate_plans: candidatePlans, params, options }) => {
482
+ try {
483
+ const contractIssues = collectFlowContractIssues({ flow, candidatePlans });
484
+ if (contractIssues.length) throw new JevBrowserError(`flow contract validation failed:\n- ${contractIssues.join("\n- ")}`);
338
485
  return toolText(await browserPool.runFlow(async () => {
339
- const args = {
340
- flow,
341
- initialUrl,
342
- expectedOutcome,
343
- candidatePlans,
344
- params,
345
- options,
346
- settings,
347
- browserPool,
348
- fetchImpl,
349
- serverVersion: packageManifest.version,
350
- mutationAuthorizationStore,
351
- };
352
- const first = await executeBrowserFlow(args);
353
- if (first.status !== "environment_error" || first.navigation_error !== "BROWSER_DISCONNECTED"
354
- || first.mutating_steps?.length || options?.confirmation_token) return first;
355
- const recovery = await browserPool.restart();
356
- if (!recovery.connected) return { ...first, browser_restart: recovery, retry_count: 0 };
357
- const retry = await executeBrowserFlow({ ...args, options: { ...options, reuse_page: false } });
358
- return { ...retry, browser_restart: recovery, retry_count: 1 };
486
+ const args = {
487
+ flow,
488
+ initialUrl,
489
+ expectedOutcome,
490
+ candidatePlans,
491
+ params,
492
+ options,
493
+ settings,
494
+ browserPool,
495
+ fetchImpl,
496
+ serverVersion: packageManifest.version,
497
+ mutationAuthorizationStore,
498
+ };
499
+ const retryAfterRestart = async (first) => {
500
+ const recovery = await browserPool.restart();
501
+ if (recovery.reason) recovery.reason = safeErrorMessage(new Error(recovery.reason), settings);
502
+ if (!recovery.connected) {
503
+ if (!initialUrl) browserPool.setNavigationError("BROWSER_PAGE_NOT_RESTORED");
504
+ return {
505
+ ...first,
506
+ ...(initialUrl ? {} : { navigation_error: "BROWSER_PAGE_NOT_RESTORED" }),
507
+ browser_restart: recovery,
508
+ retry_count: 0,
509
+ };
510
+ }
511
+ if (!initialUrl) {
512
+ browserPool.setNavigationError("BROWSER_PAGE_NOT_RESTORED");
513
+ return {
514
+ ...first,
515
+ navigation_error: "BROWSER_PAGE_NOT_RESTORED",
516
+ reason: "The browser reconnected, but the previous page was not restored. Provide initial_url to start a new navigation.",
517
+ browser_restart: recovery,
518
+ retry_count: 0,
519
+ };
520
+ }
521
+ try {
522
+ const retry = await executeBrowserFlow({ ...args, options: { ...options, reuse_page: false } });
523
+ return { ...retry, browser_restart: recovery, retry_count: 1 };
524
+ } catch (error) {
525
+ if (!isBrowserDisconnectError(error) && !isBrowserStartupError(error)) throw error;
526
+ return {
527
+ ...browserEnvironmentError(
528
+ `The browser reconnected, but the flow could not restart: ${safeErrorMessage(error, settings)}`,
529
+ "BROWSER_RESTART_FAILED",
530
+ ),
531
+ browser_restart: recovery,
532
+ retry_count: 1,
533
+ };
534
+ }
535
+ };
536
+
537
+ let first;
538
+ try {
539
+ first = await executeBrowserFlow(args);
540
+ } catch (error) {
541
+ if (isBrowserStartupError(error)) {
542
+ const profileInUse = /browser profile is in use by PID \d+/i.test(String(error.message || ""));
543
+ return browserEnvironmentError(
544
+ safeErrorMessage(error, settings),
545
+ profileInUse ? "BROWSER_PROFILE_IN_USE" : "BROWSER_START_FAILED",
546
+ { retry_count: 0 },
547
+ );
548
+ }
549
+ if (!isBrowserDisconnectError(error)) throw error;
550
+ const failedBeforeSteps = browserEnvironmentError(
551
+ "The browser disconnected before the flow could obtain its page.",
552
+ "BROWSER_DISCONNECTED",
553
+ );
554
+ return retryAfterRestart(failedBeforeSteps);
555
+ }
556
+ const disconnected = /^(?:BROWSER_DISCONNECTED|CONNECTION_CLOSED)$/.test(String(first.navigation_error || ""));
557
+ if (first.status !== "environment_error" || !disconnected
558
+ || first.steps_executed > 0 || first.mutating_steps?.length || options?.confirmation_token) return first;
559
+ return retryAfterRestart(first);
359
560
  }), settings.browser.maxToolResponseBytes);
360
561
  } catch (error) {
361
562
  return toolError(error, settings);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegosouzacdv/jev-browser-mcp",
3
- "version": "0.6.2",
3
+ "version": "0.7.1",
4
4
  "description": "Portable MCP server for bounded Playwright screen flows selected by Jev",
5
5
  "license": "MIT",
6
6
  "repository": {