@diegosouzacdv/jev-browser-mcp 0.6.2 → 0.7.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/README.md +281 -105
- package/config/ui-testing.json +2 -2
- package/docs/jev-browser-mcp.md +281 -105
- package/mcp_servers/jev-browser-npm/src/config.mjs +27 -12
- package/mcp_servers/jev-browser-npm/src/flow.mjs +1714 -865
- package/mcp_servers/jev-browser-npm/src/server.mjs +174 -27
- package/package.json +1 -1
|
@@ -7,22 +7,101 @@ 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
|
|
10
|
+
import { loadSettings, JevBrowserError } from "./config.mjs";
|
|
11
|
+
import { chooseNextAction, createMutationAuthorizationStore, describeBrowserActions, executeBrowserFlow } from "./flow.mjs";
|
|
12
|
+
|
|
13
|
+
const browserContract = describeBrowserActions();
|
|
14
|
+
const actionNames = Object.keys(browserContract.actions);
|
|
15
|
+
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
|
+
const browserOptionsShape = Object.fromEntries(browserContract.options.map((key) => {
|
|
63
|
+
const value = key === "return_snapshot" ? z.enum(["diff", "full", "none"])
|
|
64
|
+
: key === "reuse_page_match" ? z.enum(["url", "path"])
|
|
65
|
+
: key === "color_scheme" ? z.enum(["light", "dark", "no-preference"])
|
|
66
|
+
: key === "permissions" ? z.array(z.enum(["microphone", "geolocation"]))
|
|
67
|
+
: key === "console_levels" ? z.array(z.enum(["log", "info", "debug", "warn", "error"]))
|
|
68
|
+
: 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
|
+
: ["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()
|
|
71
|
+
: ["busy_selectors", "login_text", "login_url_contains"].includes(key) ? z.array(z.string())
|
|
72
|
+
: ["viewport", "geolocation", "wait_for_http", "fake_media"].includes(key) ? z.union([z.string(), jsonObject])
|
|
73
|
+
: z.string();
|
|
74
|
+
return [key, value.optional()];
|
|
75
|
+
}));
|
|
76
|
+
const browserOptionsSchema = z.object(browserOptionsShape).passthrough();
|
|
77
|
+
const expectedOutcomeSchema = z.union([
|
|
78
|
+
z.string(),
|
|
79
|
+
z.object({
|
|
80
|
+
text: z.string().optional(),
|
|
81
|
+
request: z.string().optional(),
|
|
82
|
+
method: z.string().optional(),
|
|
83
|
+
status: z.number().int().min(100).max(599).optional(),
|
|
84
|
+
message_contains: z.string().optional(),
|
|
85
|
+
}).strict().refine((value) => Boolean(value.text || value.request), "expected_outcome needs text or request"),
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
const browserPools = new Set();
|
|
14
89
|
|
|
15
90
|
class BrowserPool {
|
|
16
91
|
#settings;
|
|
17
92
|
#contextPromise;
|
|
18
93
|
#context;
|
|
19
|
-
#browser;
|
|
94
|
+
#browser;
|
|
95
|
+
#extraContexts = new Set();
|
|
96
|
+
#extraBrowsers = new Set();
|
|
97
|
+
#contextBrowsers = new Map();
|
|
20
98
|
#page;
|
|
21
99
|
#startedAt;
|
|
22
100
|
#everStarted = false;
|
|
23
101
|
#navigationError;
|
|
24
102
|
#flowQueue = Promise.resolve();
|
|
25
103
|
#configurationKey;
|
|
104
|
+
#lastConfiguration = {};
|
|
26
105
|
|
|
27
106
|
constructor(settings) {
|
|
28
107
|
this.#settings = settings;
|
|
@@ -48,7 +127,9 @@ class BrowserPool {
|
|
|
48
127
|
}
|
|
49
128
|
}
|
|
50
129
|
|
|
51
|
-
async getPage(configuration
|
|
130
|
+
async getPage(configuration) {
|
|
131
|
+
if (configuration === undefined) configuration = this.#lastConfiguration;
|
|
132
|
+
else this.#lastConfiguration = configuration;
|
|
52
133
|
const configurationKey = JSON.stringify(configuration);
|
|
53
134
|
if (this.#contextPromise && this.#configurationKey !== configurationKey) await this.close();
|
|
54
135
|
let isOpening = !this.#contextPromise;
|
|
@@ -102,7 +183,7 @@ class BrowserPool {
|
|
|
102
183
|
await this.close();
|
|
103
184
|
this.#everStarted = true;
|
|
104
185
|
this.#startedAt = performance.now();
|
|
105
|
-
this.#contextPromise = this.#open();
|
|
186
|
+
this.#contextPromise = this.#open(this.#lastConfiguration);
|
|
106
187
|
try {
|
|
107
188
|
await this.#contextPromise;
|
|
108
189
|
return { status: "connected", connected: true, recovered: true, restarted: true, current_url: this.#page.url() };
|
|
@@ -115,6 +196,44 @@ class BrowserPool {
|
|
|
115
196
|
if (this.#page !== page || !this.#context) return;
|
|
116
197
|
this.#page = this.#context.pages()[0] || await this.#context.newPage();
|
|
117
198
|
}
|
|
199
|
+
|
|
200
|
+
async createIsolatedContext(contextOptions = {}, launchArgs = []) {
|
|
201
|
+
let browser = this.#browser;
|
|
202
|
+
let ownsBrowser = false;
|
|
203
|
+
if (!browser) {
|
|
204
|
+
browser = await chromium.launch({
|
|
205
|
+
channel: this.#settings.browser.channel,
|
|
206
|
+
headless: this.#settings.browser.mode !== "computer",
|
|
207
|
+
args: launchArgs,
|
|
208
|
+
});
|
|
209
|
+
ownsBrowser = true;
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
const context = await browser.newContext(contextOptions);
|
|
213
|
+
this.#extraContexts.add(context);
|
|
214
|
+
this.#contextBrowsers.set(context, { browser, ownsBrowser });
|
|
215
|
+
context.once("close", () => this.#extraContexts.delete(context));
|
|
216
|
+
if (ownsBrowser) {
|
|
217
|
+
this.#extraBrowsers.add(browser);
|
|
218
|
+
browser.once("disconnected", () => this.#extraBrowsers.delete(browser));
|
|
219
|
+
}
|
|
220
|
+
return context;
|
|
221
|
+
} catch (error) {
|
|
222
|
+
if (ownsBrowser) await browser.close().catch(() => {});
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async releaseContext(context) {
|
|
228
|
+
const ownership = this.#contextBrowsers.get(context);
|
|
229
|
+
await context?.close().catch(() => {});
|
|
230
|
+
this.#extraContexts.delete(context);
|
|
231
|
+
this.#contextBrowsers.delete(context);
|
|
232
|
+
if (ownership?.ownsBrowser) {
|
|
233
|
+
await ownership.browser.close().catch(() => {});
|
|
234
|
+
this.#extraBrowsers.delete(ownership.browser);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
118
237
|
|
|
119
238
|
#watchContext(context, browser) {
|
|
120
239
|
context.once("close", () => {
|
|
@@ -125,8 +244,9 @@ class BrowserPool {
|
|
|
125
244
|
});
|
|
126
245
|
}
|
|
127
246
|
|
|
128
|
-
async #open({ contextOptions = {}, launchArgs = [] } = {}) {
|
|
129
|
-
const { mode, channel
|
|
247
|
+
async #open({ contextOptions = {}, launchArgs = [], profileDir: configuredProfileDir } = {}) {
|
|
248
|
+
const { mode, channel } = this.#settings.browser;
|
|
249
|
+
const profileDir = configuredProfileDir || this.#settings.browser.profileDir;
|
|
130
250
|
try {
|
|
131
251
|
if (mode === "computer") {
|
|
132
252
|
this.#context = await chromium.launchPersistentContext(profileDir, {
|
|
@@ -156,7 +276,12 @@ class BrowserPool {
|
|
|
156
276
|
}
|
|
157
277
|
}
|
|
158
278
|
|
|
159
|
-
async close() {
|
|
279
|
+
async close() {
|
|
280
|
+
await Promise.all([...this.#extraContexts].map((context) => this.releaseContext(context)));
|
|
281
|
+
await Promise.all([...this.#extraBrowsers].map((browser) => browser.close().catch(() => {})));
|
|
282
|
+
this.#extraContexts.clear();
|
|
283
|
+
this.#extraBrowsers.clear();
|
|
284
|
+
this.#contextBrowsers.clear();
|
|
160
285
|
if (this.#context) await this.#context.close().catch(() => {});
|
|
161
286
|
if (this.#browser) await this.#browser.close().catch(() => {});
|
|
162
287
|
this.#context = undefined;
|
|
@@ -290,11 +415,33 @@ export function createJevBrowserServer({ env = process.env, configPath, fetchImp
|
|
|
290
415
|
description: "Check the managed browser connection; set restart=true to close and restart only the browser session owned by this MCP process.",
|
|
291
416
|
inputSchema: { restart: z.boolean().optional() },
|
|
292
417
|
},
|
|
293
|
-
async ({ restart = false } = {}) =>
|
|
294
|
-
restart ? await browserPool.restart() : await browserPool.health()
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
418
|
+
async ({ restart = false } = {}) => {
|
|
419
|
+
const health = restart ? await browserPool.restart() : await browserPool.health();
|
|
420
|
+
return toolText({
|
|
421
|
+
...health,
|
|
422
|
+
server_version: packageManifest.version,
|
|
423
|
+
browser_mode: settings.browser.mode,
|
|
424
|
+
session_id: settings.browser.sessionId,
|
|
425
|
+
capabilities: {
|
|
426
|
+
actions: Object.keys(describeBrowserActions().actions),
|
|
427
|
+
options: describeBrowserActions().options,
|
|
428
|
+
media_permissions: ["microphone", "geolocation"],
|
|
429
|
+
fake_media: "WAV audio input",
|
|
430
|
+
storage_state: "named account snapshots",
|
|
431
|
+
contexts: ["current", "new isolated context"],
|
|
432
|
+
},
|
|
433
|
+
}, settings.browser.maxToolResponseBytes);
|
|
434
|
+
},
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
server.registerTool(
|
|
438
|
+
"describe_actions",
|
|
439
|
+
{
|
|
440
|
+
description: "Return the live JSON contract for every supported Jev Browser MCP action, option, alias, timeout and placeholder rule.",
|
|
441
|
+
inputSchema: {},
|
|
442
|
+
},
|
|
443
|
+
async () => toolText({ server_version: packageManifest.version, ...describeBrowserActions() }, settings.browser.maxToolResponseBytes),
|
|
444
|
+
);
|
|
298
445
|
|
|
299
446
|
server.registerTool(
|
|
300
447
|
"choose_next_action",
|
|
@@ -320,18 +467,18 @@ export function createJevBrowserServer({ env = process.env, configPath, fetchImp
|
|
|
320
467
|
},
|
|
321
468
|
);
|
|
322
469
|
|
|
323
|
-
server.registerTool(
|
|
324
|
-
"run_browser_flow",
|
|
325
|
-
{
|
|
326
|
-
description: "Run a bounded screen flow in
|
|
327
|
-
inputSchema: {
|
|
328
|
-
flow: z.string(),
|
|
329
|
-
initial_url: z.string(),
|
|
330
|
-
expected_outcome:
|
|
331
|
-
candidate_plans: z.record(z.string(),
|
|
332
|
-
params: z.record(z.string(), z.unknown()).optional(),
|
|
333
|
-
options:
|
|
334
|
-
},
|
|
470
|
+
server.registerTool(
|
|
471
|
+
"run_browser_flow",
|
|
472
|
+
{
|
|
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.",
|
|
474
|
+
inputSchema: {
|
|
475
|
+
flow: z.string(),
|
|
476
|
+
initial_url: z.string().optional(),
|
|
477
|
+
expected_outcome: expectedOutcomeSchema.optional(),
|
|
478
|
+
candidate_plans: z.record(z.string(), candidatePlanSchema),
|
|
479
|
+
params: z.record(z.string(), z.unknown()).optional(),
|
|
480
|
+
options: browserOptionsSchema.optional(),
|
|
481
|
+
},
|
|
335
482
|
},
|
|
336
483
|
async ({ flow, initial_url: initialUrl, expected_outcome: expectedOutcome, candidate_plans: candidatePlans, params, options }) => {
|
|
337
484
|
try {
|