@diegosouzacdv/jev-browser-mcp 0.5.0 → 0.6.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 +257 -246
- package/config/ui-testing.json +18 -18
- package/docs/jev-browser-mcp.md +257 -246
- package/mcp_servers/jev-browser-npm/src/flow.mjs +1976 -1789
- package/mcp_servers/jev-browser-npm/src/server.mjs +167 -108
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { readlink } from "node:fs/promises";
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { readlink } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { McpServer } from "@modelcontextprotocol/server";
|
|
@@ -12,27 +12,28 @@ import { chooseNextAction, executeBrowserFlow } from "./flow.mjs";
|
|
|
12
12
|
|
|
13
13
|
const browserPools = new Set();
|
|
14
14
|
|
|
15
|
-
class BrowserPool {
|
|
15
|
+
class BrowserPool {
|
|
16
16
|
#settings;
|
|
17
17
|
#contextPromise;
|
|
18
18
|
#context;
|
|
19
19
|
#browser;
|
|
20
20
|
#page;
|
|
21
|
-
#startedAt;
|
|
22
|
-
#
|
|
21
|
+
#startedAt;
|
|
22
|
+
#everStarted = false;
|
|
23
|
+
#navigationError;
|
|
23
24
|
#flowQueue = Promise.resolve();
|
|
24
25
|
|
|
25
|
-
constructor(settings) {
|
|
26
|
+
constructor(settings) {
|
|
26
27
|
this.#settings = settings;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
getNavigationError() {
|
|
30
|
-
return this.#navigationError;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
setNavigationError(error) {
|
|
34
|
-
this.#navigationError = error || undefined;
|
|
35
|
-
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
getNavigationError() {
|
|
31
|
+
return this.#navigationError;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
setNavigationError(error) {
|
|
35
|
+
this.#navigationError = error || undefined;
|
|
36
|
+
}
|
|
36
37
|
|
|
37
38
|
async runFlow(work) {
|
|
38
39
|
const previous = this.#flowQueue;
|
|
@@ -47,18 +48,60 @@ class BrowserPool {
|
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
async getPage() {
|
|
50
|
-
|
|
51
|
-
this.#page = this.#context.pages()[0] || await this.#context.newPage();
|
|
52
|
-
}
|
|
53
|
-
const isOpening = !this.#contextPromise;
|
|
51
|
+
let isOpening = !this.#contextPromise;
|
|
54
52
|
if (isOpening) {
|
|
53
|
+
this.#everStarted = true;
|
|
55
54
|
this.#startedAt = performance.now();
|
|
56
55
|
this.#contextPromise = this.#open();
|
|
57
56
|
}
|
|
58
|
-
|
|
57
|
+
try {
|
|
58
|
+
await this.#contextPromise;
|
|
59
|
+
if (this.#page?.isClosed() && this.#context) {
|
|
60
|
+
this.#page = this.#context.pages()[0] || await this.#context.newPage();
|
|
61
|
+
}
|
|
62
|
+
} catch (error) {
|
|
63
|
+
this.#invalidate("BROWSER_DISCONNECTED");
|
|
64
|
+
if (!isOpening) {
|
|
65
|
+
isOpening = true;
|
|
66
|
+
this.#startedAt = performance.now();
|
|
67
|
+
this.#contextPromise = this.#open();
|
|
68
|
+
await this.#contextPromise;
|
|
69
|
+
} else {
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
59
73
|
return { page: this.#page, context: this.#context, sessionMs: isOpening ? performance.now() - this.#startedAt : 0 };
|
|
60
74
|
}
|
|
61
75
|
|
|
76
|
+
#invalidate(reason) {
|
|
77
|
+
this.#navigationError = reason;
|
|
78
|
+
this.#context = undefined;
|
|
79
|
+
this.#browser = undefined;
|
|
80
|
+
this.#page = undefined;
|
|
81
|
+
this.#contextPromise = undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async health() {
|
|
85
|
+
if (!this.#everStarted) {
|
|
86
|
+
return { status: "not_started", connected: false };
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const { page, sessionMs } = await this.getPage();
|
|
90
|
+
return { status: "connected", connected: true, recovered: sessionMs > 0, current_url: page.url() };
|
|
91
|
+
} catch (error) {
|
|
92
|
+
return { status: "disconnected", connected: false, reason: error?.message || error?.name || "browser unavailable" };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#watchContext(context, browser) {
|
|
97
|
+
context.once("close", () => {
|
|
98
|
+
if (this.#context === context) this.#invalidate("BROWSER_DISCONNECTED");
|
|
99
|
+
});
|
|
100
|
+
browser?.once("disconnected", () => {
|
|
101
|
+
if (this.#browser === browser) this.#invalidate("BROWSER_DISCONNECTED");
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
62
105
|
async #open() {
|
|
63
106
|
const { mode, channel, profileDir } = this.#settings.browser;
|
|
64
107
|
try {
|
|
@@ -67,91 +110,98 @@ class BrowserPool {
|
|
|
67
110
|
channel,
|
|
68
111
|
headless: false,
|
|
69
112
|
});
|
|
113
|
+
this.#watchContext(this.#context);
|
|
70
114
|
this.#page = this.#context.pages()[0] || await this.#context.newPage();
|
|
71
|
-
} else {
|
|
72
|
-
this.#browser = await chromium.launch({ channel, headless: true });
|
|
73
|
-
this.#context = await this.#browser.newContext();
|
|
74
|
-
this.#
|
|
115
|
+
} else {
|
|
116
|
+
this.#browser = await chromium.launch({ channel, headless: true });
|
|
117
|
+
this.#context = await this.#browser.newContext();
|
|
118
|
+
this.#watchContext(this.#context, this.#browser);
|
|
119
|
+
this.#page = await this.#context.newPage();
|
|
75
120
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
121
|
+
this.#navigationError = undefined;
|
|
122
|
+
} catch (error) {
|
|
123
|
+
this.#invalidate("BROWSER_DISCONNECTED");
|
|
124
|
+
const ownerPid = mode === "computer" ? await activeProfileOwnerPid(profileDir) : undefined;
|
|
125
|
+
if (ownerPid) {
|
|
126
|
+
throw new JevBrowserError(`browser profile is in use by PID ${ownerPid}; set JEV_BROWSER_PROFILE to a separate profile directory`);
|
|
127
|
+
}
|
|
128
|
+
throw new JevBrowserError(
|
|
129
|
+
`Could not start the configured ${channel} browser (${error?.name || "BrowserError"}); install it with jev-browser-mcp --install-browser`,
|
|
130
|
+
);
|
|
85
131
|
}
|
|
86
132
|
}
|
|
87
133
|
|
|
88
|
-
async close() {
|
|
89
|
-
if (this.#context) await this.#context.close().catch(() => {});
|
|
90
|
-
if (this.#browser) await this.#browser.close().catch(() => {});
|
|
134
|
+
async close() {
|
|
135
|
+
if (this.#context) await this.#context.close().catch(() => {});
|
|
136
|
+
if (this.#browser) await this.#browser.close().catch(() => {});
|
|
91
137
|
this.#context = undefined;
|
|
92
138
|
this.#browser = undefined;
|
|
93
|
-
this.#page = undefined;
|
|
94
|
-
this.#contextPromise = undefined;
|
|
95
|
-
this.#navigationError = undefined;
|
|
139
|
+
this.#page = undefined;
|
|
140
|
+
this.#contextPromise = undefined;
|
|
141
|
+
this.#navigationError = undefined;
|
|
96
142
|
}
|
|
97
143
|
}
|
|
98
144
|
|
|
99
|
-
function toolError(error) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
145
|
+
function toolError(error, settings) {
|
|
146
|
+
let message = error instanceof JevBrowserError ? error.message : error?.message || `Jev browser flow failed (${error?.name || "Error"})`;
|
|
147
|
+
const credential = settings.env?.[settings.jev.credentialEnv];
|
|
148
|
+
if (credential) message = message.split(credential).join("[redacted]");
|
|
149
|
+
message = message.replace(/\bBearer\s+\S+/gi, "Bearer [redacted]")
|
|
150
|
+
.replace(/([?&](?:token|api[_-]?key|password|secret)=)[^&\s]+/gi, "$1[redacted]");
|
|
151
|
+
return { isError: true, content: [{ type: "text", text: `[jev-browser ${packageManifest.version}] ${message}` }] };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function toolText(value) {
|
|
155
|
+
const versioned = value && typeof value === "object" && !Array.isArray(value)
|
|
156
|
+
? { ...value, server_version: packageManifest.version }
|
|
157
|
+
: { result: value, server_version: packageManifest.version };
|
|
158
|
+
return { content: [{ type: "text", text: JSON.stringify(versioned) }] };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function profileOwnerPidFromProcessList(processes, profileDir, platform = process.platform) {
|
|
162
|
+
const normalize = platform === "win32" ? path.win32.normalize : path.resolve;
|
|
163
|
+
const expectedProfile = normalize(profileDir).replace(/[\\/]+$/, "").toLowerCase();
|
|
164
|
+
for (const processInfo of Array.isArray(processes) ? processes : [processes]) {
|
|
165
|
+
if (!processInfo || typeof processInfo !== "object") continue;
|
|
166
|
+
const commandLine = typeof processInfo.CommandLine === "string" ? processInfo.CommandLine : "";
|
|
167
|
+
const match = commandLine.match(/(?:^|\s)--user-data-dir(?:=|\s+)(?:"([^"]+)"|'([^']+)'|([^\s"']+))/i);
|
|
168
|
+
const processProfile = match?.[1] || match?.[2] || match?.[3];
|
|
169
|
+
const pid = Number(processInfo.ProcessId ?? processInfo.pid);
|
|
170
|
+
if (!processProfile || !Number.isSafeInteger(pid) || pid <= 0) continue;
|
|
171
|
+
if (normalize(processProfile).replace(/[\\/]+$/, "").toLowerCase() === expectedProfile) return pid;
|
|
172
|
+
}
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function activeProfileOwnerPid(profileDir) {
|
|
177
|
+
try {
|
|
178
|
+
const lockTarget = await readlink(path.join(profileDir, "SingletonLock"));
|
|
179
|
+
const pid = Number(path.basename(lockTarget).match(/-(\d+)$/)?.[1]);
|
|
180
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return undefined;
|
|
181
|
+
try {
|
|
182
|
+
process.kill(pid, 0);
|
|
183
|
+
return pid;
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if (error?.code === "EPERM") return pid;
|
|
186
|
+
}
|
|
187
|
+
} catch {
|
|
188
|
+
// Windows holds profile ownership in a process lock rather than a PID symlink.
|
|
189
|
+
}
|
|
190
|
+
if (process.platform !== "win32") return undefined;
|
|
191
|
+
const script = "$ErrorActionPreference='Stop'; Get-CimInstance Win32_Process -Filter \"Name='chrome.exe' OR Name='msedge.exe'\" | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress";
|
|
192
|
+
const listing = spawnSync("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], {
|
|
193
|
+
encoding: "utf8",
|
|
194
|
+
timeout: 2000,
|
|
195
|
+
windowsHide: true,
|
|
196
|
+
});
|
|
197
|
+
if (listing.status !== 0 || !listing.stdout?.trim()) return undefined;
|
|
198
|
+
try {
|
|
199
|
+
const processes = JSON.parse(listing.stdout);
|
|
200
|
+
return profileOwnerPidFromProcessList(processes, profileDir);
|
|
201
|
+
} catch {
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
155
205
|
|
|
156
206
|
export function createJevBrowserServer({ env = process.env, configPath, fetchImpl = fetch } = {}) {
|
|
157
207
|
const settings = loadSettings({ env, ...(configPath ? { configPath } : {}) });
|
|
@@ -172,6 +222,15 @@ export function createJevBrowserServer({ env = process.env, configPath, fetchImp
|
|
|
172
222
|
}
|
|
173
223
|
};
|
|
174
224
|
|
|
225
|
+
server.registerTool(
|
|
226
|
+
"browser_health",
|
|
227
|
+
{
|
|
228
|
+
description: "Check whether the managed browser session is connected. If a previously started browser exited, reconnects it and reports whether recovery succeeded.",
|
|
229
|
+
inputSchema: {},
|
|
230
|
+
},
|
|
231
|
+
async () => toolText(await browserPool.health()),
|
|
232
|
+
);
|
|
233
|
+
|
|
175
234
|
server.registerTool(
|
|
176
235
|
"choose_next_action",
|
|
177
236
|
{
|
|
@@ -187,7 +246,7 @@ export function createJevBrowserServer({ env = process.env, configPath, fetchImp
|
|
|
187
246
|
try {
|
|
188
247
|
return toolText(await chooseNextAction({ flow, pageSnapshot, actions, completedSteps, settings, fetchImpl }));
|
|
189
248
|
} catch (error) {
|
|
190
|
-
return toolError(error);
|
|
249
|
+
return toolError(error, settings);
|
|
191
250
|
}
|
|
192
251
|
},
|
|
193
252
|
);
|
|
@@ -195,32 +254,32 @@ export function createJevBrowserServer({ env = process.env, configPath, fetchImp
|
|
|
195
254
|
server.registerTool(
|
|
196
255
|
"run_browser_flow",
|
|
197
256
|
{
|
|
198
|
-
description: "Run a bounded screen flow in a warm Playwright session. Supports role and label locators, exact row scopes, guarded dialog confirmation, network assertions, variables and extraction, 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.",
|
|
257
|
+
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.",
|
|
199
258
|
inputSchema: {
|
|
200
259
|
flow: z.string(),
|
|
201
260
|
initial_url: z.string(),
|
|
202
261
|
expected_outcome: z.string(),
|
|
203
|
-
candidate_plans: z.record(z.string(), z.unknown()),
|
|
204
|
-
params: z.record(z.string(), z.unknown()).optional(),
|
|
205
|
-
options: z.record(z.string(), z.unknown()).optional(),
|
|
206
|
-
},
|
|
207
|
-
},
|
|
208
|
-
async ({ flow, initial_url: initialUrl, expected_outcome: expectedOutcome, candidate_plans: candidatePlans, params, options }) => {
|
|
262
|
+
candidate_plans: z.record(z.string(), z.unknown()),
|
|
263
|
+
params: z.record(z.string(), z.unknown()).optional(),
|
|
264
|
+
options: z.record(z.string(), z.unknown()).optional(),
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
async ({ flow, initial_url: initialUrl, expected_outcome: expectedOutcome, candidate_plans: candidatePlans, params, options }) => {
|
|
209
268
|
try {
|
|
210
269
|
return toolText(await browserPool.runFlow(() => executeBrowserFlow({
|
|
211
270
|
flow,
|
|
212
271
|
initialUrl,
|
|
213
272
|
expectedOutcome,
|
|
214
|
-
candidatePlans,
|
|
215
|
-
params,
|
|
273
|
+
candidatePlans,
|
|
274
|
+
params,
|
|
216
275
|
options,
|
|
217
276
|
settings,
|
|
218
277
|
browserPool,
|
|
219
|
-
fetchImpl,
|
|
220
|
-
serverVersion: packageManifest.version,
|
|
278
|
+
fetchImpl,
|
|
279
|
+
serverVersion: packageManifest.version,
|
|
221
280
|
})));
|
|
222
281
|
} catch (error) {
|
|
223
|
-
return toolError(error);
|
|
282
|
+
return toolError(error, settings);
|
|
224
283
|
}
|
|
225
284
|
},
|
|
226
285
|
);
|