@diegosouzacdv/jev-browser-mcp 0.7.0 → 0.7.2

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.
@@ -1,79 +1,43 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { readlink } from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { fileURLToPath } from "node:url";
5
- import { McpServer } from "@modelcontextprotocol/server";
4
+ import { fileURLToPath } from "node:url";
5
+ import { McpServer } from "@modelcontextprotocol/server";
6
6
  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
10
  import { loadSettings, JevBrowserError } from "./config.mjs";
11
- import { chooseNextAction, createMutationAuthorizationStore, describeBrowserActions, executeBrowserFlow } from "./flow.mjs";
11
+ import { chooseNextAction, collectFlowContractIssues, createMutationAuthorizationStore, describeBrowserActions, executeBrowserFlow } from "./flow.mjs";
12
+ import { createBrowserFlowRunStore } from "./browser-flow-runs.mjs";
13
+ import { executeTestSuite } from "./test-suites.mjs";
14
+ import { cleanupUploadStagingDirectory, prepareUploadStagingDirectory } from "./upload-staging.mjs";
12
15
 
13
16
  const browserContract = describeBrowserActions();
14
- const actionNames = Object.keys(browserContract.actions);
15
17
  const jsonObject = z.record(z.string(), z.unknown());
16
- function browserStepFieldSchema(field) {
17
- if (["mutating", "screenshot", "expect_download", "expect_popup", "blur", "sensitive", "full_page"].includes(field)) return z.boolean();
18
- if (["timeout_ms", "timeout_seconds"].includes(field)) return z.number().positive();
19
- if (["index", "context_index", "ms", "duration_ms", "status", "close_code"].includes(field)) return z.number().int();
20
- if (field === "file_paths") return z.union([z.string(), z.array(z.string())]);
21
- if (["near", "within", "if_visible", "json"].includes(field)) return jsonObject;
22
- if (field === "name_match") return z.enum(["exact", "contains", "regex"]);
23
- if (field === "condition") return z.enum(["network_idle", "angular_idle", "url", "hidden"]);
24
- if (field === "standard") return z.enum(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa", "section508"]);
25
- return z.string();
26
- }
27
- function browserStepVariant(action, contract, aliases = []) {
28
- const required = new Set(contract.required);
29
- const optional = new Set([...contract.optional, "timeout_ms", "mutating", "screenshot", ...aliases]);
30
- if (action === "wait") { required.delete("ms"); optional.add("ms"); }
31
- if (action === "wait_for_text") { required.delete("text"); optional.add("text"); }
32
- if (action === "assert_text" || action === "assert_value") { required.delete("expected"); optional.add("expected"); }
33
- if (action === "upload_file") { required.delete("file_paths"); optional.add("file_paths"); }
34
- if (action === "wait") optional.add("duration_ms");
35
- if (action === "wait_for_text" || action === "assert_text" || action === "assert_value") optional.add("value");
36
- if (action === "upload_file") optional.add("file_path");
37
- if (action === "confirm_modal") {
38
- required.delete("button");
39
- optional.add("button");
40
- optional.add("trigger");
41
- optional.add("confirm");
42
- }
43
- const shape = { action: z.literal(action) };
44
- for (const key of new Set([...required, ...optional])) {
45
- const field = browserStepFieldSchema(key);
46
- shape[key] = required.has(key) ? field : field.optional();
47
- }
48
- return z.object(shape).strict();
49
- }
50
- const browserStepVariants = Object.entries(browserContract.actions).map(([action, contract]) => browserStepVariant(action, contract));
51
- browserStepVariants.push(
52
- browserStepVariant("fill", browserContract.actions.type, ["value"]),
53
- browserStepVariant("press_key", browserContract.actions.press),
54
- );
55
- const browserStepSchema = z.discriminatedUnion("action", browserStepVariants);
56
- const candidatePlanSchema = z.object({
57
- description: z.string(),
58
- steps: z.array(browserStepSchema).min(1),
59
- comment: z.string().optional(),
60
- when: z.object({ visible: z.string() }).strict().optional(),
61
- }).strict();
62
18
  const browserOptionsShape = Object.fromEntries(browserContract.options.map((key) => {
63
19
  const value = key === "return_snapshot" ? z.enum(["diff", "full", "none"])
20
+ : key === "capture_network" ? z.enum(["none", "errors", "all"])
64
21
  : key === "reuse_page_match" ? z.enum(["url", "path"])
65
22
  : key === "color_scheme" ? z.enum(["light", "dark", "no-preference"])
66
23
  : key === "permissions" ? z.array(z.enum(["microphone", "geolocation"]))
67
24
  : key === "console_levels" ? z.array(z.enum(["log", "info", "debug", "warn", "error"]))
68
25
  : key === "ready" ? z.union([z.string(), z.record(z.string(), z.unknown()), z.array(z.union([z.string(), z.record(z.string(), z.unknown())]))])
69
26
  : ["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()
70
- : ["max_flow_steps", "ready_stable_ms", "ready_timeout_seconds", "step_timeout_seconds"].includes(key) ? z.number().positive()
27
+ : key === "max_flow_steps" ? z.number().int().positive()
28
+ : ["ready_stable_ms", "ready_timeout_seconds", "step_timeout_seconds"].includes(key) ? z.number().positive()
71
29
  : ["busy_selectors", "login_text", "login_url_contains"].includes(key) ? z.array(z.string())
72
30
  : ["viewport", "geolocation", "wait_for_http", "fake_media"].includes(key) ? z.union([z.string(), jsonObject])
73
31
  : z.string();
74
32
  return [key, value.optional()];
75
33
  }));
76
34
  const browserOptionsSchema = z.object(browserOptionsShape).passthrough();
35
+ const candidatePlanSchema = z.object({
36
+ description: z.string().optional(),
37
+ steps: z.array(z.record(z.string(), z.unknown())).optional(),
38
+ comment: z.string().optional(),
39
+ when: z.unknown().optional(),
40
+ }).strict();
77
41
  const expectedOutcomeSchema = z.union([
78
42
  z.string(),
79
43
  z.object({
@@ -85,7 +49,7 @@ const expectedOutcomeSchema = z.union([
85
49
  }).strict().refine((value) => Boolean(value.text || value.request), "expected_outcome needs text or request"),
86
50
  ]);
87
51
 
88
- const browserPools = new Set();
52
+ const browserPools = new Map();
89
53
 
90
54
  class BrowserPool {
91
55
  #settings;
@@ -97,8 +61,9 @@ class BrowserPool {
97
61
  #contextBrowsers = new Map();
98
62
  #page;
99
63
  #startedAt;
100
- #everStarted = false;
101
- #navigationError;
64
+ #everStarted = false;
65
+ #navigationError;
66
+ #unrestoredPage = false;
102
67
  #flowQueue = Promise.resolve();
103
68
  #configurationKey;
104
69
  #lastConfiguration = {};
@@ -107,12 +72,13 @@ class BrowserPool {
107
72
  this.#settings = settings;
108
73
  }
109
74
 
110
- getNavigationError() {
111
- return this.#navigationError;
112
- }
113
-
114
- setNavigationError(error) {
115
- this.#navigationError = error || undefined;
75
+ getNavigationError() {
76
+ return this.#unrestoredPage ? "BROWSER_PAGE_NOT_RESTORED" : this.#navigationError;
77
+ }
78
+
79
+ setNavigationError(error) {
80
+ this.#navigationError = error || undefined;
81
+ this.#unrestoredPage = error === "BROWSER_PAGE_NOT_RESTORED";
116
82
  }
117
83
 
118
84
  async runFlow(work) {
@@ -125,9 +91,10 @@ class BrowserPool {
125
91
  } finally {
126
92
  release();
127
93
  }
128
- }
129
-
94
+ }
95
+
130
96
  async getPage(configuration) {
97
+ const hadStartedBeforeRequest = this.#everStarted;
131
98
  if (configuration === undefined) configuration = this.#lastConfiguration;
132
99
  else this.#lastConfiguration = configuration;
133
100
  const configurationKey = JSON.stringify(configuration);
@@ -156,7 +123,12 @@ class BrowserPool {
156
123
  throw error;
157
124
  }
158
125
  }
159
- return { page: this.#page, context: this.#context, sessionMs: isOpening ? performance.now() - this.#startedAt : 0 };
126
+ return {
127
+ page: this.#page,
128
+ context: this.#context,
129
+ sessionMs: isOpening ? performance.now() - this.#startedAt : 0,
130
+ recovered: isOpening && hadStartedBeforeRequest,
131
+ };
160
132
  }
161
133
 
162
134
  #invalidate(reason) {
@@ -290,17 +262,47 @@ class BrowserPool {
290
262
  this.#contextPromise = undefined;
291
263
  this.#navigationError = undefined;
292
264
  this.#configurationKey = undefined;
265
+ this.#unrestoredPage = false;
293
266
  }
294
267
  }
295
268
 
296
- function toolError(error, settings) {
297
- let message = error instanceof JevBrowserError ? error.message : error?.message || `Jev browser flow failed (${error?.name || "Error"})`;
298
- const credential = settings.env?.[settings.jev.credentialEnv];
299
- if (credential) message = message.split(credential).join("[redacted]");
300
- message = message.replace(/\bBearer\s+\S+/gi, "Bearer [redacted]")
301
- .replace(/([?&](?:token|api[_-]?key|password|secret)=)[^&\s]+/gi, "$1[redacted]");
302
- return { isError: true, content: [{ type: "text", text: `[jev-browser ${packageManifest.version}] ${message}` }] };
303
- }
269
+ function safeErrorMessage(error, settings) {
270
+ let message = error instanceof JevBrowserError ? error.message : error?.message || `Jev browser flow failed (${error?.name || "Error"})`;
271
+ const credential = settings.env?.[settings.jev.credentialEnv];
272
+ if (credential) message = message.split(credential).join("[redacted]");
273
+ message = message.replace(/\bBearer\s+\S+/gi, "Bearer [redacted]")
274
+ .replace(/([?&](?:token|api[_-]?key|password|secret)=)[^&\s]+/gi, "$1[redacted]");
275
+ return message;
276
+ }
277
+
278
+ function toolError(error, settings) {
279
+ const message = safeErrorMessage(error, settings);
280
+ return { isError: true, content: [{ type: "text", text: `[jev-browser ${packageManifest.version}] ${message}` }] };
281
+ }
282
+
283
+ function isBrowserDisconnectError(error) {
284
+ 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
285
+ .test(String(error?.message || error?.name || error || ""));
286
+ }
287
+
288
+ function isBrowserStartupError(error) {
289
+ return /(?:could not start the configured .* browser|browser profile is in use by PID \d+)/i
290
+ .test(String(error?.message || error?.name || error || ""));
291
+ }
292
+
293
+ function browserEnvironmentError(reason, navigationError, extra = {}) {
294
+ return {
295
+ server_version: packageManifest.version,
296
+ status: "environment_error",
297
+ navigation_error: navigationError,
298
+ reason,
299
+ acceptance_criteria_met: false,
300
+ expected_outcome_visible: false,
301
+ steps_executed: 0,
302
+ steps: [],
303
+ ...extra,
304
+ };
305
+ }
304
306
 
305
307
  export function fitToolResponse(value, maxBytes) {
306
308
  const clone = structuredClone(value && typeof value === "object" ? value : { result: value });
@@ -389,46 +391,66 @@ async function activeProfileOwnerPid(profileDir) {
389
391
  }
390
392
  }
391
393
 
392
- export function createJevBrowserServer({ env = process.env, configPath, fetchImpl = fetch } = {}) {
393
- const settings = loadSettings({ env, ...(configPath ? { configPath } : {}) });
394
- const browserPool = new BrowserPool(settings);
395
- const mutationAuthorizationStore = createMutationAuthorizationStore();
396
- browserPools.add(browserPool);
394
+ export function createJevBrowserServer({ env = process.env, configPath, fetchImpl = fetch } = {}) {
395
+ const settings = loadSettings({ env, ...(configPath ? { configPath } : {}) });
396
+ const browserPool = new BrowserPool(settings);
397
+ const mutationAuthorizationStore = createMutationAuthorizationStore();
398
+ const flowRunStore = createBrowserFlowRunStore({
399
+ maxEntries: settings.browser.maxBackgroundFlowRuns,
400
+ retentionMs: settings.browser.backgroundFlowResultTtlMs,
401
+ });
402
+ browserPools.set(browserPool, settings);
397
403
  const server = new McpServer({
398
404
  name: "jev-browser",
399
405
  version: packageManifest.version,
400
406
  description: "Bounded Playwright screen flows selected by Jev through the OpenRouter Decisions API.",
401
407
  });
402
408
  const closeServer = server.close.bind(server);
403
- server.close = async () => {
404
- try {
405
- await closeServer();
406
- } finally {
407
- await browserPool.close();
408
- browserPools.delete(browserPool);
409
- }
410
- };
409
+ server.close = async () => {
410
+ try {
411
+ await closeServer();
412
+ } finally {
413
+ try {
414
+ await browserPool.close();
415
+ } finally {
416
+ browserPools.delete(browserPool);
417
+ await cleanupUploadStagingDirectory(settings);
418
+ }
419
+ }
420
+ };
411
421
 
412
422
  server.registerTool(
413
423
  "browser_health",
414
424
  {
415
425
  description: "Check the managed browser connection; set restart=true to close and restart only the browser session owned by this MCP process.",
416
426
  inputSchema: { restart: z.boolean().optional() },
417
- },
427
+ },
418
428
  async ({ restart = false } = {}) => {
419
429
  const health = restart ? await browserPool.restart() : await browserPool.health();
430
+ const uploadStagingDirectory = settings.browser.managedUploadStaging
431
+ ? await prepareUploadStagingDirectory(settings)
432
+ : undefined;
420
433
  return toolText({
421
434
  ...health,
422
435
  server_version: packageManifest.version,
423
436
  browser_mode: settings.browser.mode,
424
437
  session_id: settings.browser.sessionId,
438
+ ...(uploadStagingDirectory ? { upload_staging_directory: uploadStagingDirectory } : {}),
425
439
  capabilities: {
426
440
  actions: Object.keys(describeBrowserActions().actions),
427
441
  options: describeBrowserActions().options,
442
+ limits: describeBrowserActions(settings).limits,
443
+ environment_profiles: Object.keys(settings.browser.environmentProfiles.profiles),
444
+ profile_environments: Object.fromEntries(Object.entries(settings.browser.environmentProfiles.profiles).map(([name, profile]) => [name, profile.environment])),
445
+ test_suite_directory: ".jev-browser/suites",
446
+ suite_report_format: "Markdown",
447
+ database_engines: ["postgresql", "oracle"],
448
+ remote_file_protocols: ["ftp", "ftps", "sftp"],
428
449
  media_permissions: ["microphone", "geolocation"],
429
450
  fake_media: "WAV audio input",
430
451
  storage_state: "named account snapshots",
431
452
  contexts: ["current", "new isolated context"],
453
+ file_upload_staging: settings.browser.managedUploadStaging ? "private project-local fixtures and uploads; managed files are cleaned after flows and suites" : "managed project-local staging unavailable",
432
454
  },
433
455
  }, settings.browser.maxToolResponseBytes);
434
456
  },
@@ -440,7 +462,7 @@ export function createJevBrowserServer({ env = process.env, configPath, fetchImp
440
462
  description: "Return the live JSON contract for every supported Jev Browser MCP action, option, alias, timeout and placeholder rule.",
441
463
  inputSchema: {},
442
464
  },
443
- async () => toolText({ server_version: packageManifest.version, ...describeBrowserActions() }, settings.browser.maxToolResponseBytes),
465
+ async () => toolText({ server_version: packageManifest.version, ...describeBrowserActions(settings) }, settings.browser.maxToolResponseBytes),
444
466
  );
445
467
 
446
468
  server.registerTool(
@@ -470,53 +492,186 @@ export function createJevBrowserServer({ env = process.env, configPath, fetchImp
470
492
  server.registerTool(
471
493
  "run_browser_flow",
472
494
  {
473
- 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.",
495
+ description: "Run a bounded screen flow in an isolated per-server browser profile. Set background=true to return a run_id immediately and poll get_browser_flow_result instead of holding a long MCP call open. 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.",
474
496
  inputSchema: {
475
- flow: z.string(),
497
+ flow: z.string().optional(),
476
498
  initial_url: z.string().optional(),
477
499
  expected_outcome: expectedOutcomeSchema.optional(),
478
- candidate_plans: z.record(z.string(), candidatePlanSchema),
500
+ candidate_plans: z.record(z.string(), candidatePlanSchema).optional(),
479
501
  params: z.record(z.string(), z.unknown()).optional(),
480
502
  options: browserOptionsSchema.optional(),
503
+ background: z.boolean().optional(),
481
504
  },
482
- },
483
- async ({ flow, initial_url: initialUrl, expected_outcome: expectedOutcome, candidate_plans: candidatePlans, params, options }) => {
484
- try {
485
- return toolText(await browserPool.runFlow(async () => {
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 first = await executeBrowserFlow(args);
500
- if (first.status !== "environment_error" || first.navigation_error !== "BROWSER_DISCONNECTED"
501
- || first.mutating_steps?.length || options?.confirmation_token) return first;
502
- const recovery = await browserPool.restart();
503
- if (!recovery.connected) return { ...first, browser_restart: recovery, retry_count: 0 };
504
- const retry = await executeBrowserFlow({ ...args, options: { ...options, reuse_page: false } });
505
- return { ...retry, browser_restart: recovery, retry_count: 1 };
506
- }), settings.browser.maxToolResponseBytes);
507
- } catch (error) {
508
- return toolError(error, settings);
509
- }
510
- },
511
- );
512
-
513
- return server;
514
- }
505
+ },
506
+ async ({ flow, initial_url: initialUrl, expected_outcome: expectedOutcome, candidate_plans: candidatePlans, params, options, background = false }) => {
507
+ try {
508
+ const contractIssues = collectFlowContractIssues({ flow, candidatePlans });
509
+ if (contractIssues.length) throw new JevBrowserError(`flow contract validation failed:\n- ${contractIssues.join("\n- ")}`);
510
+ const execute = () => browserPool.runFlow(async () => {
511
+ const args = {
512
+ flow,
513
+ initialUrl,
514
+ expectedOutcome,
515
+ candidatePlans,
516
+ params,
517
+ options,
518
+ settings,
519
+ browserPool,
520
+ fetchImpl,
521
+ serverVersion: packageManifest.version,
522
+ mutationAuthorizationStore,
523
+ };
524
+ const retryAfterRestart = async (first) => {
525
+ const recovery = await browserPool.restart();
526
+ if (recovery.reason) recovery.reason = safeErrorMessage(new Error(recovery.reason), settings);
527
+ if (!recovery.connected) {
528
+ if (!initialUrl) browserPool.setNavigationError("BROWSER_PAGE_NOT_RESTORED");
529
+ return {
530
+ ...first,
531
+ ...(initialUrl ? {} : { navigation_error: "BROWSER_PAGE_NOT_RESTORED" }),
532
+ browser_restart: recovery,
533
+ retry_count: 0,
534
+ };
535
+ }
536
+ if (!initialUrl) {
537
+ browserPool.setNavigationError("BROWSER_PAGE_NOT_RESTORED");
538
+ return {
539
+ ...first,
540
+ navigation_error: "BROWSER_PAGE_NOT_RESTORED",
541
+ reason: "The browser reconnected, but the previous page was not restored. Provide initial_url to start a new navigation.",
542
+ browser_restart: recovery,
543
+ retry_count: 0,
544
+ };
545
+ }
546
+ try {
547
+ const retry = await executeBrowserFlow({ ...args, options: { ...options, reuse_page: false } });
548
+ return { ...retry, browser_restart: recovery, retry_count: 1 };
549
+ } catch (error) {
550
+ if (!isBrowserDisconnectError(error) && !isBrowserStartupError(error)) throw error;
551
+ return {
552
+ ...browserEnvironmentError(
553
+ `The browser reconnected, but the flow could not restart: ${safeErrorMessage(error, settings)}`,
554
+ "BROWSER_RESTART_FAILED",
555
+ ),
556
+ browser_restart: recovery,
557
+ retry_count: 1,
558
+ };
559
+ }
560
+ };
561
+
562
+ let first;
563
+ try {
564
+ first = await executeBrowserFlow(args);
565
+ } catch (error) {
566
+ if (isBrowserStartupError(error)) {
567
+ const profileInUse = /browser profile is in use by PID \d+/i.test(String(error.message || ""));
568
+ return browserEnvironmentError(
569
+ safeErrorMessage(error, settings),
570
+ profileInUse ? "BROWSER_PROFILE_IN_USE" : "BROWSER_START_FAILED",
571
+ { retry_count: 0 },
572
+ );
573
+ }
574
+ if (!isBrowserDisconnectError(error)) throw error;
575
+ const failedBeforeSteps = browserEnvironmentError(
576
+ "The browser disconnected before the flow could obtain its page.",
577
+ "BROWSER_DISCONNECTED",
578
+ );
579
+ return retryAfterRestart(failedBeforeSteps);
580
+ }
581
+ const disconnected = /^(?:BROWSER_DISCONNECTED|CONNECTION_CLOSED)$/.test(String(first.navigation_error || ""));
582
+ if (first.status !== "environment_error" || !disconnected
583
+ || first.steps_executed > 0 || first.mutating_steps?.length || options?.confirmation_token) return first;
584
+ return retryAfterRestart(first);
585
+ });
586
+ if (background) {
587
+ const started = flowRunStore.start(async () => {
588
+ try {
589
+ return fitToolResponse(await execute(), Math.floor(settings.browser.maxToolResponseBytes * 0.8));
590
+ } catch (error) {
591
+ throw new Error(safeErrorMessage(error, settings));
592
+ }
593
+ });
594
+ return toolText(started, settings.browser.maxToolResponseBytes);
595
+ }
596
+ return toolText(await execute(), settings.browser.maxToolResponseBytes);
597
+ } catch (error) {
598
+ return toolError(error, settings);
599
+ }
600
+ },
601
+ );
602
+
603
+ server.registerTool(
604
+ "run_test_suite",
605
+ {
606
+ description: "Run a validated JSON suite from .jev-browser/suites/<suite_name>.json sequentially, retaining the browser and integration session across setup, acceptance criteria and always-attempted teardown. Produces a Markdown evidence report and cleans project-local fixtures after teardown. Set background=true to poll the long run with get_browser_flow_result.",
607
+ inputSchema: {
608
+ suite_name: z.string().min(1).max(80),
609
+ params: z.record(z.string(), z.unknown()).optional(),
610
+ environment_profile: z.string().min(1).max(48).optional(),
611
+ report_path: z.string().min(1).max(2048).optional(),
612
+ background: z.boolean().optional(),
613
+ },
614
+ },
615
+ async ({ suite_name: suiteName, params, environment_profile: environmentProfile, report_path: reportPath, background = false }) => {
616
+ try {
617
+ const execute = () => browserPool.runFlow(() => executeTestSuite({
618
+ suiteName,
619
+ params,
620
+ environmentProfile,
621
+ reportPath,
622
+ settings,
623
+ browserPool,
624
+ fetchImpl,
625
+ serverVersion: packageManifest.version,
626
+ mutationAuthorizationStore,
627
+ executeFlow: executeBrowserFlow,
628
+ }));
629
+ if (background) {
630
+ const started = flowRunStore.start(async () => {
631
+ try { return fitToolResponse(await execute(), Math.floor(settings.browser.maxToolResponseBytes * 0.8)); }
632
+ catch (error) { throw new Error(safeErrorMessage(error, settings)); }
633
+ });
634
+ return toolText(started, settings.browser.maxToolResponseBytes);
635
+ }
636
+ return toolText(await execute(), settings.browser.maxToolResponseBytes);
637
+ } catch (error) {
638
+ return toolError(error, settings);
639
+ }
640
+ },
641
+ );
642
+
643
+ server.registerTool(
644
+ "get_browser_flow_result",
645
+ {
646
+ description: "Poll a background run_browser_flow call. Set wait_ms up to 30000 to long-poll; completed results are retained for 30 minutes in this MCP process.",
647
+ inputSchema: {
648
+ run_id: z.string().min(1),
649
+ wait_ms: z.number().int().min(0).max(30000).optional(),
650
+ },
651
+ },
652
+ async ({ run_id: runId, wait_ms: waitMs = 0 }) => {
653
+ const result = await flowRunStore.get(runId, waitMs);
654
+ if (!result) return toolError(new JevBrowserError("background flow run was not found or its result expired"), settings);
655
+ return toolText(result, settings.browser.maxToolResponseBytes);
656
+ },
657
+ );
658
+
659
+ return server;
660
+ }
515
661
 
516
- export async function closeBrowserSessions() {
517
- await Promise.all([...browserPools].map((pool) => pool.close()));
518
- browserPools.clear();
519
- }
662
+ export async function closeBrowserSessions() {
663
+ try {
664
+ await Promise.all([...browserPools].map(async ([pool, settings]) => {
665
+ try {
666
+ await pool.close();
667
+ } finally {
668
+ await cleanupUploadStagingDirectory(settings);
669
+ }
670
+ }));
671
+ } finally {
672
+ browserPools.clear();
673
+ }
674
+ }
520
675
 
521
676
  export function installConfiguredBrowser({ env = process.env } = {}) {
522
677
  const settings = loadSettings({ env });