@diegosouzacdv/jev-browser-mcp 0.7.1 → 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,14 +1,17 @@
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
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
17
  const jsonObject = z.record(z.string(), z.unknown());
@@ -21,7 +24,8 @@ const browserOptionsShape = Object.fromEntries(browserContract.options.map((key)
21
24
  : key === "console_levels" ? z.array(z.enum(["log", "info", "debug", "warn", "error"]))
22
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())]))])
23
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()
24
- : ["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()
25
29
  : ["busy_selectors", "login_text", "login_url_contains"].includes(key) ? z.array(z.string())
26
30
  : ["viewport", "geolocation", "wait_for_http", "fake_media"].includes(key) ? z.union([z.string(), jsonObject])
27
31
  : z.string();
@@ -45,7 +49,7 @@ const expectedOutcomeSchema = z.union([
45
49
  }).strict().refine((value) => Boolean(value.text || value.request), "expected_outcome needs text or request"),
46
50
  ]);
47
51
 
48
- const browserPools = new Set();
52
+ const browserPools = new Map();
49
53
 
50
54
  class BrowserPool {
51
55
  #settings;
@@ -387,46 +391,66 @@ async function activeProfileOwnerPid(profileDir) {
387
391
  }
388
392
  }
389
393
 
390
- export function createJevBrowserServer({ env = process.env, configPath, fetchImpl = fetch } = {}) {
391
- const settings = loadSettings({ env, ...(configPath ? { configPath } : {}) });
392
- const browserPool = new BrowserPool(settings);
393
- const mutationAuthorizationStore = createMutationAuthorizationStore();
394
- 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);
395
403
  const server = new McpServer({
396
404
  name: "jev-browser",
397
405
  version: packageManifest.version,
398
406
  description: "Bounded Playwright screen flows selected by Jev through the OpenRouter Decisions API.",
399
407
  });
400
408
  const closeServer = server.close.bind(server);
401
- server.close = async () => {
402
- try {
403
- await closeServer();
404
- } finally {
405
- await browserPool.close();
406
- browserPools.delete(browserPool);
407
- }
408
- };
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
+ };
409
421
 
410
422
  server.registerTool(
411
423
  "browser_health",
412
424
  {
413
425
  description: "Check the managed browser connection; set restart=true to close and restart only the browser session owned by this MCP process.",
414
426
  inputSchema: { restart: z.boolean().optional() },
415
- },
427
+ },
416
428
  async ({ restart = false } = {}) => {
417
429
  const health = restart ? await browserPool.restart() : await browserPool.health();
430
+ const uploadStagingDirectory = settings.browser.managedUploadStaging
431
+ ? await prepareUploadStagingDirectory(settings)
432
+ : undefined;
418
433
  return toolText({
419
434
  ...health,
420
435
  server_version: packageManifest.version,
421
436
  browser_mode: settings.browser.mode,
422
437
  session_id: settings.browser.sessionId,
438
+ ...(uploadStagingDirectory ? { upload_staging_directory: uploadStagingDirectory } : {}),
423
439
  capabilities: {
424
440
  actions: Object.keys(describeBrowserActions().actions),
425
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"],
426
449
  media_permissions: ["microphone", "geolocation"],
427
450
  fake_media: "WAV audio input",
428
451
  storage_state: "named account snapshots",
429
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",
430
454
  },
431
455
  }, settings.browser.maxToolResponseBytes);
432
456
  },
@@ -438,7 +462,7 @@ export function createJevBrowserServer({ env = process.env, configPath, fetchImp
438
462
  description: "Return the live JSON contract for every supported Jev Browser MCP action, option, alias, timeout and placeholder rule.",
439
463
  inputSchema: {},
440
464
  },
441
- async () => toolText({ server_version: packageManifest.version, ...describeBrowserActions() }, settings.browser.maxToolResponseBytes),
465
+ async () => toolText({ server_version: packageManifest.version, ...describeBrowserActions(settings) }, settings.browser.maxToolResponseBytes),
442
466
  );
443
467
 
444
468
  server.registerTool(
@@ -468,7 +492,7 @@ export function createJevBrowserServer({ env = process.env, configPath, fetchImp
468
492
  server.registerTool(
469
493
  "run_browser_flow",
470
494
  {
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.",
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.",
472
496
  inputSchema: {
473
497
  flow: z.string().optional(),
474
498
  initial_url: z.string().optional(),
@@ -476,13 +500,14 @@ export function createJevBrowserServer({ env = process.env, configPath, fetchImp
476
500
  candidate_plans: z.record(z.string(), candidatePlanSchema).optional(),
477
501
  params: z.record(z.string(), z.unknown()).optional(),
478
502
  options: browserOptionsSchema.optional(),
503
+ background: z.boolean().optional(),
479
504
  },
480
505
  },
481
- async ({ flow, initial_url: initialUrl, expected_outcome: expectedOutcome, candidate_plans: candidatePlans, params, options }) => {
506
+ async ({ flow, initial_url: initialUrl, expected_outcome: expectedOutcome, candidate_plans: candidatePlans, params, options, background = false }) => {
482
507
  try {
483
508
  const contractIssues = collectFlowContractIssues({ flow, candidatePlans });
484
509
  if (contractIssues.length) throw new JevBrowserError(`flow contract validation failed:\n- ${contractIssues.join("\n- ")}`);
485
- return toolText(await browserPool.runFlow(async () => {
510
+ const execute = () => browserPool.runFlow(async () => {
486
511
  const args = {
487
512
  flow,
488
513
  initialUrl,
@@ -557,20 +582,96 @@ export function createJevBrowserServer({ env = process.env, configPath, fetchImp
557
582
  if (first.status !== "environment_error" || !disconnected
558
583
  || first.steps_executed > 0 || first.mutating_steps?.length || options?.confirmation_token) return first;
559
584
  return retryAfterRestart(first);
560
- }), settings.browser.maxToolResponseBytes);
561
- } catch (error) {
562
- return toolError(error, settings);
563
- }
564
- },
565
- );
566
-
567
- return server;
568
- }
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
+ }
569
661
 
570
- export async function closeBrowserSessions() {
571
- await Promise.all([...browserPools].map((pool) => pool.close()));
572
- browserPools.clear();
573
- }
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
+ }
574
675
 
575
676
  export function installConfiguredBrowser({ env = process.env } = {}) {
576
677
  const settings = loadSettings({ env });