@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.
- package/README.md +280 -55
- package/config/ui-testing.json +31 -10
- package/docs/jev-browser-mcp.md +280 -55
- package/mcp_servers/jev-browser-npm/bin/jev-browser-mcp.cjs +4 -1
- package/mcp_servers/jev-browser-npm/src/browser-flow-runs.mjs +57 -0
- package/mcp_servers/jev-browser-npm/src/config.mjs +136 -37
- package/mcp_servers/jev-browser-npm/src/environment-profiles.mjs +241 -0
- package/mcp_servers/jev-browser-npm/src/flow.mjs +650 -201
- package/mcp_servers/jev-browser-npm/src/server.mjs +136 -35
- package/mcp_servers/jev-browser-npm/src/test-integrations.mjs +655 -0
- package/mcp_servers/jev-browser-npm/src/test-suites.mjs +438 -0
- package/mcp_servers/jev-browser-npm/src/upload-staging.mjs +80 -0
- package/package.json +7 -1
|
@@ -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
|
-
:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
406
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
572
|
-
|
|
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 });
|