@mindstone/mcp-server-browser-automation 0.2.0 → 0.2.1
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 +7 -14
- package/dist/path-safety.d.ts +25 -7
- package/dist/path-safety.js +41 -10
- package/dist/tools/interaction.js +22 -27
- package/dist/tools/observation.js +6 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Browser control you can watch: open pages, sign in, click around, fill forms, ta
|
|
|
9
9
|
|
|
10
10
|
## Status
|
|
11
11
|
|
|
12
|
-
- **Version:** [0.2.
|
|
12
|
+
- **Version:** [0.2.1](./CHANGELOG.md) · [npm](https://www.npmjs.com/package/@mindstone/mcp-server-browser-automation)
|
|
13
13
|
- **Auth:** None ([`server.json`](./server.json))
|
|
14
14
|
- **Tools:** [21](./src/tools/) (navigation, observation, interaction, sessions, files)
|
|
15
15
|
- **Surface:** browser-automation
|
|
@@ -117,7 +117,6 @@ No API keys or credentials are required. The server communicates with the browse
|
|
|
117
117
|
|---|---|---|
|
|
118
118
|
| `AGENT_BROWSER_SESSION_NAME` | No | Session name for browser persistence (default: `mcp`) |
|
|
119
119
|
| `AGENT_BROWSER_SHOW_WINDOW` | No | Set to `false` to run without a visible browser window. Default is visible (`true`). |
|
|
120
|
-
| `BROWSER_AUTOMATION_ALLOW_EVAL` | No | Set to `1` to register the `browser_evaluate` tool. Off by default. See [Security notes](#security-notes). |
|
|
121
120
|
| `MCP_WORKSPACE_PATH` | No | Workspace directory that `browser_pdf` writes into and `browser_upload` reads from. Defaults to the system temp directory. See [Security notes](#security-notes). |
|
|
122
121
|
|
|
123
122
|
### MCP Host Configuration
|
|
@@ -133,7 +132,7 @@ No API keys or credentials are required. The server communicates with the browse
|
|
|
133
132
|
}
|
|
134
133
|
```
|
|
135
134
|
|
|
136
|
-
## Available Tools (
|
|
135
|
+
## Available Tools (21)
|
|
137
136
|
|
|
138
137
|
### Navigation
|
|
139
138
|
- **browser_navigate** — Navigate to a URL
|
|
@@ -157,7 +156,7 @@ No API keys or credentials are required. The server communicates with the browse
|
|
|
157
156
|
- **browser_select** — Select an option from a dropdown
|
|
158
157
|
- **browser_hover** — Hover over an element
|
|
159
158
|
- **browser_upload** — Upload workspace files to a file input (regular files only, staged privately before upload)
|
|
160
|
-
- **browser_evaluate** — Execute JavaScript in the page context (
|
|
159
|
+
- **browser_evaluate** — Execute JavaScript in the page context (on by default; `destructiveHint: true` so hosts can require confirmation — see [Security notes](#security-notes))
|
|
161
160
|
|
|
162
161
|
### Session Management
|
|
163
162
|
- **browser_tabs** — List open tabs or switch to a tab
|
|
@@ -177,15 +176,9 @@ The typical workflow uses accessibility snapshots for reliable element targeting
|
|
|
177
176
|
|
|
178
177
|
Browser automation has a large attack surface: the agent-browser CLI controls a real headless browser that loads URLs you pass it, runs page-side JavaScript, and persists cookies and session state across runs. Read this section before deploying.
|
|
179
178
|
|
|
180
|
-
### `browser_evaluate` is
|
|
179
|
+
### `browser_evaluate` runs by default — host confirmation is the gate
|
|
181
180
|
|
|
182
|
-
`browser_evaluate` lets the model execute arbitrary JavaScript inside the page context — the security equivalent of giving the model a shell on whatever site it has just navigated to.
|
|
183
|
-
|
|
184
|
-
```bash
|
|
185
|
-
BROWSER_AUTOMATION_ALLOW_EVAL=1 mcp-server-browser-automation
|
|
186
|
-
```
|
|
187
|
-
|
|
188
|
-
Without this env var, `browser_evaluate` is **not** in the tools list at all — the LLM cannot even see it. When enabled, the tool is marked so MCP hosts can require explicit user confirmation before each invocation.
|
|
181
|
+
`browser_evaluate` lets the model execute arbitrary JavaScript inside the page context — the security equivalent of giving the model a shell on whatever site it has just navigated to. The tool is registered **unconditionally** (capability-first); the safeguard is the host's tool-approval layer. The tool is marked `destructiveHint: true`, so MCP hosts SHOULD require explicit user confirmation before each invocation. Do not configure a host to auto-approve this tool.
|
|
189
182
|
|
|
190
183
|
### URL scheme deny-list
|
|
191
184
|
|
|
@@ -207,7 +200,7 @@ To override the session name (for example, to keep separate profiles per project
|
|
|
207
200
|
### Recommended deployment posture
|
|
208
201
|
|
|
209
202
|
- **Run the connector against a separate browser profile** — a dedicated `AGENT_BROWSER_SESSION_NAME` per MCP host. Do not reuse your daily browser profile: the connector reads and overwrites cookies in whichever profile it is pointed at, and a malicious page can ride the existing session of any site you are logged into.
|
|
210
|
-
- **
|
|
203
|
+
- **Require host confirmation for `browser_evaluate`** — it runs by default; every call executes arbitrary JavaScript in the page context.
|
|
211
204
|
- **Require host confirmation** for `browser_authenticate` and any flow that may navigate to authenticated sites — otherwise prompt injection in fetched content can drive the browser at sites the user is logged into.
|
|
212
205
|
- **Returned page content is enveloped as untrusted** — accessibility snapshots, page text, titles, URLs, tab lists, and `browser_evaluate` outputs come from arbitrary websites and may contain prompt-injection attempts. The connector wraps them in `<untrusted-content source="…">` envelopes (with close-tag breakout escaping) so hosts and models treat them as data, not instructions. Keep that treatment on your side: don't strip the envelopes, and don't let page text alone trigger irreversible actions.
|
|
213
206
|
|
|
@@ -219,7 +212,7 @@ Both tools are marked `destructiveHint: true`, so hosts can require user confirm
|
|
|
219
212
|
|
|
220
213
|
Additional hardening:
|
|
221
214
|
|
|
222
|
-
- **Staged file hand-off.** Validated files are never re-opened by pathname. `browser_upload` sources are opened once, verified to be regular files (directories, devices, and FIFOs are refused), and copied into a fresh private staging directory that the CLI consumes
|
|
215
|
+
- **Staged file hand-off.** Validated files are never re-opened by pathname. `browser_upload` sources are opened once, verified to be regular files (directories, devices, and FIFOs are refused), and copied into a fresh private staging directory that the CLI consumes. `browser_pdf` has the CLI write into a fresh private staging directory, then installs the PDF at the requested path: the destination directory's canonical identity is pinned before the CLI runs and re-verified before installing, so an intermediate directory swapped to a symlink mid-call is refused, and exclusive-create semantics refuse a file or symlink planted at the destination leaf instead of writing through it.
|
|
223
216
|
- **No silent overwrite.** `browser_pdf` refuses an existing `file_path` with a `FILE_EXISTS` error unless the caller explicitly passes `overwrite: true`.
|
|
224
217
|
- **Enveloped errors.** Error output from the `agent-browser` CLI can contain page-authored text; it is wrapped in `<untrusted-content>` envelopes (with close-tag breakout escaping) before reaching the model, and timeout errors do not echo the command's argument values.
|
|
225
218
|
|
package/dist/path-safety.d.ts
CHANGED
|
@@ -60,6 +60,14 @@ export declare function stageUploadSource(inputPath: string, stagingDir: string,
|
|
|
60
60
|
export interface PdfStagingTarget {
|
|
61
61
|
/** Validated final destination for the PDF. */
|
|
62
62
|
destPath: string;
|
|
63
|
+
/**
|
|
64
|
+
* Canonical realpath of destPath's parent directory, pinned at validation
|
|
65
|
+
* time (the parent is created before the CLI runs). installStagedFile
|
|
66
|
+
* re-verifies it before writing, so an intermediate directory swapped to
|
|
67
|
+
* a symlink during the CLI call is refused instead of followed outside
|
|
68
|
+
* the workspace.
|
|
69
|
+
*/
|
|
70
|
+
canonicalParentDir: string;
|
|
63
71
|
/** Private staging path the CLI should write to. */
|
|
64
72
|
stagingPath: string;
|
|
65
73
|
/** Remove with discardStagingDir once installed (or on failure). */
|
|
@@ -79,12 +87,22 @@ export declare function createPdfStagingTarget(filePath: string, overwrite: bool
|
|
|
79
87
|
/**
|
|
80
88
|
* Install a staged PDF at its validated destination.
|
|
81
89
|
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
90
|
+
* Two defences, one per path component class:
|
|
91
|
+
* - Leaf: the install never writes through a pre-existing filesystem
|
|
92
|
+
* entry. Exclusive-create (COPYFILE_EXCL) fails with EEXIST on anything
|
|
93
|
+
* already at the destination — including a planted symlink. With
|
|
94
|
+
* `overwrite`, the old entry is removed first and the same
|
|
95
|
+
* exclusive-create follows, so a leaf swap planted in between surfaces
|
|
96
|
+
* as an EEXIST refusal instead of a write through an attacker-chosen
|
|
97
|
+
* path.
|
|
98
|
+
* - Intermediate directories: exclusive-create guards only the final
|
|
99
|
+
* component, so the parent's canonical identity (pinned at validation,
|
|
100
|
+
* before the CLI ran) is re-verified immediately before installing. A
|
|
101
|
+
* directory swapped to a symlink during the CLI call is refused rather
|
|
102
|
+
* than followed outside the workspace — for the write and, with
|
|
103
|
+
* `overwrite`, for the preceding delete. (A swap landing after this
|
|
104
|
+
* re-check but before the copy is a sub-millisecond residual window, on
|
|
105
|
+
* par with any local check-then-use race.)
|
|
88
106
|
*/
|
|
89
|
-
export declare function installStagedFile(
|
|
107
|
+
export declare function installStagedFile(target: PdfStagingTarget, overwrite: boolean): void;
|
|
90
108
|
//# sourceMappingURL=path-safety.d.ts.map
|
package/dist/path-safety.js
CHANGED
|
@@ -233,9 +233,20 @@ export function createPdfStagingTarget(filePath, overwrite) {
|
|
|
233
233
|
if (!overwrite && fs.existsSync(resolved.path)) {
|
|
234
234
|
throw new ConnectorError(`file_path already exists: ${filePath}`, 'FILE_EXISTS', 'Pass overwrite: true to replace the existing file, or choose a different file_path.');
|
|
235
235
|
}
|
|
236
|
+
// Create the parent directory NOW, before the multi-second CLI call, and
|
|
237
|
+
// pin its canonical identity. resolved.path is canonical, so the realpath
|
|
238
|
+
// of the freshly prepared parent must equal it exactly; a mismatch means
|
|
239
|
+
// a component changed under us and the destination cannot be trusted.
|
|
240
|
+
const parentDir = path.dirname(resolved.path);
|
|
241
|
+
fs.mkdirSync(parentDir, { recursive: true });
|
|
242
|
+
const canonicalParentDir = fs.realpathSync(parentDir);
|
|
243
|
+
if (canonicalParentDir !== parentDir) {
|
|
244
|
+
throw new ConnectorError(`file_path parent directory changed while it was being prepared: ${filePath}`, 'PATH_OUTSIDE_WORKSPACE', 'Retry the call, or choose a different file_path inside the workspace directory.');
|
|
245
|
+
}
|
|
236
246
|
const stagingDir = createStagingDir('browser-pdf-');
|
|
237
247
|
return {
|
|
238
248
|
destPath: resolved.path,
|
|
249
|
+
canonicalParentDir,
|
|
239
250
|
stagingPath: path.join(stagingDir, sanitiseBasename(resolved.path, 'page.pdf')),
|
|
240
251
|
stagingDir,
|
|
241
252
|
};
|
|
@@ -243,14 +254,25 @@ export function createPdfStagingTarget(filePath, overwrite) {
|
|
|
243
254
|
/**
|
|
244
255
|
* Install a staged PDF at its validated destination.
|
|
245
256
|
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
257
|
+
* Two defences, one per path component class:
|
|
258
|
+
* - Leaf: the install never writes through a pre-existing filesystem
|
|
259
|
+
* entry. Exclusive-create (COPYFILE_EXCL) fails with EEXIST on anything
|
|
260
|
+
* already at the destination — including a planted symlink. With
|
|
261
|
+
* `overwrite`, the old entry is removed first and the same
|
|
262
|
+
* exclusive-create follows, so a leaf swap planted in between surfaces
|
|
263
|
+
* as an EEXIST refusal instead of a write through an attacker-chosen
|
|
264
|
+
* path.
|
|
265
|
+
* - Intermediate directories: exclusive-create guards only the final
|
|
266
|
+
* component, so the parent's canonical identity (pinned at validation,
|
|
267
|
+
* before the CLI ran) is re-verified immediately before installing. A
|
|
268
|
+
* directory swapped to a symlink during the CLI call is refused rather
|
|
269
|
+
* than followed outside the workspace — for the write and, with
|
|
270
|
+
* `overwrite`, for the preceding delete. (A swap landing after this
|
|
271
|
+
* re-check but before the copy is a sub-millisecond residual window, on
|
|
272
|
+
* par with any local check-then-use race.)
|
|
252
273
|
*/
|
|
253
|
-
export function installStagedFile(
|
|
274
|
+
export function installStagedFile(target, overwrite) {
|
|
275
|
+
const { stagingPath, destPath, canonicalParentDir } = target;
|
|
254
276
|
let staged;
|
|
255
277
|
try {
|
|
256
278
|
staged = fs.lstatSync(stagingPath);
|
|
@@ -261,12 +283,21 @@ export function installStagedFile(stagingPath, destPath, overwrite) {
|
|
|
261
283
|
if (!staged.isFile()) {
|
|
262
284
|
throw new ConnectorError('The agent-browser CLI did not produce a regular output file.', 'CLI_ERROR', 'Retry the call, or check that a page is open in the browser session.');
|
|
263
285
|
}
|
|
286
|
+
const parentDir = path.dirname(destPath);
|
|
287
|
+
let currentParentDir;
|
|
288
|
+
try {
|
|
289
|
+
currentParentDir = fs.realpathSync(parentDir);
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
293
|
+
throw new ConnectorError(`Destination directory cannot be resolved (${reason}): ${destPath}`, 'PATH_OUTSIDE_WORKSPACE', 'Retry the call, or choose a different file_path inside the workspace directory.');
|
|
294
|
+
}
|
|
295
|
+
if (currentParentDir !== canonicalParentDir) {
|
|
296
|
+
throw new ConnectorError(`Destination directory changed while the PDF was being generated: ${destPath}`, 'PATH_OUTSIDE_WORKSPACE', 'Retry the call, or choose a different file_path inside the workspace directory.');
|
|
297
|
+
}
|
|
264
298
|
if (overwrite) {
|
|
265
299
|
fs.rmSync(destPath, { force: true });
|
|
266
300
|
}
|
|
267
|
-
// Parent directories were validated as inside the workspace by
|
|
268
|
-
// resolveWorkspaceWritePath; create any missing tail before installing.
|
|
269
|
-
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
270
301
|
try {
|
|
271
302
|
fs.copyFileSync(stagingPath, destPath, fs.constants.COPYFILE_EXCL);
|
|
272
303
|
}
|
|
@@ -119,32 +119,27 @@ WORKFLOW: browser_snapshot → find input @ref → browser_fill`,
|
|
|
119
119
|
}));
|
|
120
120
|
// M3.12 — `browser_evaluate` lets the model run arbitrary JavaScript inside
|
|
121
121
|
// the page context, which is the security equivalent of giving it a shell
|
|
122
|
-
// on whatever site it has just navigated to.
|
|
123
|
-
//
|
|
124
|
-
// host
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}, withErrorHandling(async (args) => {
|
|
145
|
-
const result = await execAgentBrowser(['eval', args.script]);
|
|
146
|
-
return JSON.stringify({ ok: true, result: wrapUntrusted(result.stdout.trim(), 'browser-automation:evaluate') });
|
|
147
|
-
}));
|
|
148
|
-
}
|
|
122
|
+
// on whatever site it has just navigated to. The tool is registered
|
|
123
|
+
// unconditionally (capability-first): it carries `destructiveHint: true`
|
|
124
|
+
// so the host's tool-approval layer can require explicit user confirmation
|
|
125
|
+
// before each invocation.
|
|
126
|
+
server.registerTool('browser_evaluate', // eslint-disable-line @typescript-eslint/quotes
|
|
127
|
+
{
|
|
128
|
+
description: 'Execute JavaScript in the page context and return the result. ' +
|
|
129
|
+
'DESTRUCTIVE: this is equivalent to running arbitrary code with the privileges of the current page; ' +
|
|
130
|
+
'hosts SHOULD require user confirmation before each call.',
|
|
131
|
+
inputSchema: {
|
|
132
|
+
script: z.string().describe('JavaScript code to execute'),
|
|
133
|
+
},
|
|
134
|
+
annotations: {
|
|
135
|
+
readOnlyHint: false,
|
|
136
|
+
destructiveHint: true,
|
|
137
|
+
idempotentHint: false,
|
|
138
|
+
openWorldHint: true,
|
|
139
|
+
},
|
|
140
|
+
}, withErrorHandling(async (args) => {
|
|
141
|
+
const result = await execAgentBrowser(['eval', args.script]);
|
|
142
|
+
return JSON.stringify({ ok: true, result: wrapUntrusted(result.stdout.trim(), 'browser-automation:evaluate') });
|
|
143
|
+
}));
|
|
149
144
|
}
|
|
150
145
|
//# sourceMappingURL=interaction.js.map
|
|
@@ -115,14 +115,15 @@ The file is written inside the workspace directory (MCP_WORKSPACE_PATH, or the s
|
|
|
115
115
|
openWorldHint: true,
|
|
116
116
|
},
|
|
117
117
|
}, withErrorHandling(async (args) => {
|
|
118
|
-
// The CLI writes into a private staging dir
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
118
|
+
// The CLI writes into a private staging dir. Install then re-verifies
|
|
119
|
+
// the destination directory's canonical identity (pinned before the
|
|
120
|
+
// CLI ran) and writes with exclusive-create, so a post-validation swap
|
|
121
|
+
// of an intermediate directory or the leaf is refused rather than
|
|
122
|
+
// followed outside the workspace.
|
|
122
123
|
const target = createPdfStagingTarget(args.file_path, args.overwrite);
|
|
123
124
|
try {
|
|
124
125
|
await execAgentBrowser(['pdf', target.stagingPath]);
|
|
125
|
-
installStagedFile(target
|
|
126
|
+
installStagedFile(target, args.overwrite);
|
|
126
127
|
return JSON.stringify({ ok: true, file_path: target.destPath });
|
|
127
128
|
}
|
|
128
129
|
finally {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mindstone/mcp-server-browser-automation",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"mcpName": "io.github.mindstone/mcp-server-browser-automation",
|
|
5
5
|
"description": "Browser automation MCP server — visible-by-default browser control via accessibility snapshots, navigation, form filling, screenshots, and tab management. Set AGENT_BROWSER_SHOW_WINDOW=false to run quietly.",
|
|
6
6
|
"license": "FSL-1.1-MIT",
|