@mehmoodqureshi/chrome-mcp 0.4.2 → 0.5.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 +51 -11
- package/dist/shared/mutex.d.ts +18 -0
- package/dist/shared/mutex.js +42 -0
- package/dist/shared/screenshot.d.ts +58 -0
- package/dist/shared/screenshot.js +54 -0
- package/dist/src/executor/cdp-executor.d.ts +3 -1
- package/dist/src/executor/cdp-executor.js +3 -1
- package/dist/src/executor/extension-executor.d.ts +3 -1
- package/dist/src/executor/extension-executor.js +2 -2
- package/dist/src/executor/stub-executor.d.ts +3 -1
- package/dist/src/executor/stub-executor.js +1 -1
- package/dist/src/executor/types.d.ts +4 -1
- package/dist/src/mcp/batch.d.ts +26 -0
- package/dist/src/mcp/batch.js +130 -0
- package/dist/src/mcp/tools.js +29 -3
- package/docs/BLUEPRINT.md +7 -2
- package/extension-dist/background.js +170 -42
- package/extension-dist/manifest.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -84,14 +84,52 @@ OS dialog (requires `--enable-uploads`).
|
|
|
84
84
|
`click`/`type` accept `trusted: true` for real OS-level input (works on
|
|
85
85
|
React/Vue controlled inputs); interactions auto-wait for the target to appear.
|
|
86
86
|
|
|
87
|
+
### Driving several tabs at once — `batch`
|
|
88
|
+
|
|
89
|
+
`batch` runs many tool calls in **one** request — `parallel` (default) or
|
|
90
|
+
`serial` (with optional `stopOnError`). Each sub-op goes through the **same**
|
|
91
|
+
policy gate, rate limit, and error handling as a direct call (no bypass,
|
|
92
|
+
no nesting). Use it to fan work out across tabs:
|
|
93
|
+
|
|
94
|
+
```jsonc
|
|
95
|
+
// open three product pages (background, so they don't fight for focus)…
|
|
96
|
+
{ "name": "batch", "arguments": { "ops": [
|
|
97
|
+
{ "tool": "tab_new", "args": { "url": "https://a.example/p" } },
|
|
98
|
+
{ "tool": "tab_new", "args": { "url": "https://b.example/p" } },
|
|
99
|
+
{ "tool": "tab_new", "args": { "url": "https://c.example/p" } }
|
|
100
|
+
]}}
|
|
101
|
+
|
|
102
|
+
// …then read them all at once (wall-clock ≈ the slowest one, not the sum)
|
|
103
|
+
{ "name": "batch", "arguments": { "ops": [
|
|
104
|
+
{ "tool": "get_text", "args": { "tabId": "<a tabId>" } },
|
|
105
|
+
{ "tool": "get_text", "args": { "tabId": "<b tabId>" } },
|
|
106
|
+
{ "tool": "get_text", "args": { "tabId": "<c tabId>" } }
|
|
107
|
+
]}}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
In `parallel` mode, tab-scoped ops **must** pass an explicit `tabId` — the
|
|
111
|
+
active-tab default is unsafe under concurrency, so it's rejected rather than
|
|
112
|
+
silently mis-routed. (`tab_new`, `tabs_list`, `chrome_status` are exempt.)
|
|
113
|
+
|
|
114
|
+
> **`tab_new` focuses the new tab by default** (so "open X" behaves like opening
|
|
115
|
+
> a link, instead of replacing your current page — use `tab_new`, not
|
|
116
|
+
> `navigate`, to open without losing the current tab). Pass `active: false` to
|
|
117
|
+
> open in the background; parallel batches do this automatically.
|
|
118
|
+
|
|
87
119
|
## Status
|
|
88
120
|
|
|
89
|
-
v0.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
121
|
+
v0.5.0 — **safe multi-tab concurrency.** Adds the `batch` fan-out tool, makes
|
|
122
|
+
parallel tab automation race-free (explicit-`tabId` guard; per-tab
|
|
123
|
+
`chrome.debugger` serialization; collision-free `tab_new`), captures screenshots
|
|
124
|
+
via `chrome.debugger` (a specific tab without stealing focus — plus true
|
|
125
|
+
full-page and element capture), and focuses newly opened tabs by default. 111
|
|
126
|
+
automated tests + a gated headed extension smoke.
|
|
127
|
+
|
|
128
|
+
v0.2.0 — all six build phases complete and green. End-to-end working:
|
|
129
|
+
`npx chrome-mcp` ⇄ bridge ⇄ extension ⇄ your real Chrome, with a Playwright CDP
|
|
130
|
+
fallback. v0.2 adds the accessibility `snapshot` + element refs, auto-wait,
|
|
131
|
+
cookies/storage/`select_option`, trusted input (`chrome.debugger`), a toolbar
|
|
132
|
+
status badge, and a stable pairing token (`--persist-token`).
|
|
95
133
|
|
|
96
134
|
- [x] **Phase 0 — Contracts & skeleton:** `shared/protocol.ts` (wire contract),
|
|
97
135
|
`src/executor/types.ts` (Executor interface), `src/security/policy.ts`
|
|
@@ -162,8 +200,10 @@ from the extension's **Options** page using the `port` + `token` from
|
|
|
162
200
|
`~/.chrome-mcp/handshake.json` (run `npx chrome-mcp --print-pairing` to get the
|
|
163
201
|
path).
|
|
164
202
|
|
|
165
|
-
> **
|
|
166
|
-
>
|
|
167
|
-
>
|
|
168
|
-
> OS-level
|
|
169
|
-
>
|
|
203
|
+
> **Reads/interaction use `chrome.scripting`/`chrome.tabs`** — no "is being
|
|
204
|
+
> debugged" banner, CSP-safe reads (isolated world), testable under Playwright.
|
|
205
|
+
> `chrome.debugger` is used only where it's needed and worth it: `trusted: true`
|
|
206
|
+
> input (real OS-level events on React/Vue inputs) and `screenshot` (captures a
|
|
207
|
+
> specific tab **without** activating it — safe under parallel `batch` — with
|
|
208
|
+
> true full-page and element capture). Those ops briefly show the debug banner
|
|
209
|
+
> while attached.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shared/mutex.ts — a tiny per-key async lock.
|
|
3
|
+
*
|
|
4
|
+
* Calls sharing a key run one-at-a-time in FIFO order; different keys run
|
|
5
|
+
* concurrently. Pure promises, no platform deps, so it's reusable by the
|
|
6
|
+
* extension service worker and unit-testable without Chrome.
|
|
7
|
+
*
|
|
8
|
+
* Used in the SW to serialize chrome.debugger attach→work→detach per tab (a
|
|
9
|
+
* second attach on the same tab throws, and one op's detach would yank the
|
|
10
|
+
* debugger from a concurrent op) while keeping different tabs parallel.
|
|
11
|
+
*/
|
|
12
|
+
export declare class KeyedMutex {
|
|
13
|
+
private readonly tails;
|
|
14
|
+
/** Run `fn` after all earlier holders of `key` settle. Resolves/rejects with fn's outcome. */
|
|
15
|
+
run<T>(key: string, fn: () => Promise<T> | T): Promise<T>;
|
|
16
|
+
/** Number of keys with an outstanding or queued holder (for tests/inspection). */
|
|
17
|
+
get size(): number;
|
|
18
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* shared/mutex.ts — a tiny per-key async lock.
|
|
4
|
+
*
|
|
5
|
+
* Calls sharing a key run one-at-a-time in FIFO order; different keys run
|
|
6
|
+
* concurrently. Pure promises, no platform deps, so it's reusable by the
|
|
7
|
+
* extension service worker and unit-testable without Chrome.
|
|
8
|
+
*
|
|
9
|
+
* Used in the SW to serialize chrome.debugger attach→work→detach per tab (a
|
|
10
|
+
* second attach on the same tab throws, and one op's detach would yank the
|
|
11
|
+
* debugger from a concurrent op) while keeping different tabs parallel.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.KeyedMutex = void 0;
|
|
15
|
+
function noop() {
|
|
16
|
+
/* swallow — the tracker must never reject so the queue keeps flowing */
|
|
17
|
+
}
|
|
18
|
+
class KeyedMutex {
|
|
19
|
+
tails = new Map();
|
|
20
|
+
/** Run `fn` after all earlier holders of `key` settle. Resolves/rejects with fn's outcome. */
|
|
21
|
+
run(key, fn) {
|
|
22
|
+
const prev = this.tails.get(key) ?? Promise.resolve();
|
|
23
|
+
// Run fn whether the previous holder resolved or rejected (its failure must
|
|
24
|
+
// not poison this one).
|
|
25
|
+
const result = prev.then(fn, fn);
|
|
26
|
+
// A non-rejecting tracker the next waiter chains on.
|
|
27
|
+
const tail = result.then(noop, noop);
|
|
28
|
+
this.tails.set(key, tail);
|
|
29
|
+
void tail.then(() => {
|
|
30
|
+
// Only drop the entry if no newer waiter has replaced it.
|
|
31
|
+
if (this.tails.get(key) === tail)
|
|
32
|
+
this.tails.delete(key);
|
|
33
|
+
});
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
/** Number of keys with an outstanding or queued holder (for tests/inspection). */
|
|
37
|
+
get size() {
|
|
38
|
+
return this.tails.size;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
exports.KeyedMutex = KeyedMutex;
|
|
42
|
+
//# sourceMappingURL=mutex.js.map
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shared/screenshot.ts — pure screenshot-planning logic, shared so it can be
|
|
3
|
+
* unit-tested without Chrome and reused by the extension SW.
|
|
4
|
+
*
|
|
5
|
+
* Turns measured page dimensions (and an optional element rect) into the
|
|
6
|
+
* `Page.captureScreenshot` clip + the logical dimensions/truncation flags the
|
|
7
|
+
* `ScreenshotResult` reports. No chrome.* calls — just arithmetic.
|
|
8
|
+
*/
|
|
9
|
+
/** Measured page geometry, in CSS pixels. */
|
|
10
|
+
export interface PageDims {
|
|
11
|
+
/** Viewport width/height. */
|
|
12
|
+
w: number;
|
|
13
|
+
h: number;
|
|
14
|
+
/** Full content box (document) width/height. */
|
|
15
|
+
fullW: number;
|
|
16
|
+
fullH: number;
|
|
17
|
+
}
|
|
18
|
+
/** An element's box in DOCUMENT coordinates (viewport rect + scroll offset), CSS px. */
|
|
19
|
+
export interface ElementRect {
|
|
20
|
+
x: number;
|
|
21
|
+
y: number;
|
|
22
|
+
w: number;
|
|
23
|
+
h: number;
|
|
24
|
+
}
|
|
25
|
+
/** A CDP `Page.captureScreenshot` clip (CSS px; `scale` multiplies output). */
|
|
26
|
+
export interface CaptureClip {
|
|
27
|
+
x: number;
|
|
28
|
+
y: number;
|
|
29
|
+
width: number;
|
|
30
|
+
height: number;
|
|
31
|
+
scale: number;
|
|
32
|
+
}
|
|
33
|
+
export interface ScreenshotPlan {
|
|
34
|
+
/** Omitted for a plain viewport capture (capture whatever is visible). */
|
|
35
|
+
clip?: CaptureClip;
|
|
36
|
+
/** Must be true whenever a clip reaches outside the current viewport. */
|
|
37
|
+
captureBeyondViewport: boolean;
|
|
38
|
+
/** Logical (CSS px) dimensions to report back in ScreenshotResult. */
|
|
39
|
+
width: number;
|
|
40
|
+
height: number;
|
|
41
|
+
/** The capture was clamped below the real content/element height. */
|
|
42
|
+
truncated: boolean;
|
|
43
|
+
/** The true height when `truncated` (or for any fullPage/element capture). */
|
|
44
|
+
fullHeight?: number;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Practical single-capture height ceiling. Skia/CDP cannot encode arbitrarily
|
|
48
|
+
* tall images; beyond this we clamp the clip and flag `truncated`.
|
|
49
|
+
*/
|
|
50
|
+
export declare const MAX_CAPTURE_PX = 16384;
|
|
51
|
+
/**
|
|
52
|
+
* Plan a capture. Element clip wins over fullPage; fullPage wins over the plain
|
|
53
|
+
* viewport capture. Heights are clamped to MAX_CAPTURE_PX with `truncated` set.
|
|
54
|
+
*/
|
|
55
|
+
export declare function planScreenshot(dims: PageDims, opts?: {
|
|
56
|
+
fullPage?: boolean;
|
|
57
|
+
element?: ElementRect | null;
|
|
58
|
+
}): ScreenshotPlan;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* shared/screenshot.ts — pure screenshot-planning logic, shared so it can be
|
|
4
|
+
* unit-tested without Chrome and reused by the extension SW.
|
|
5
|
+
*
|
|
6
|
+
* Turns measured page dimensions (and an optional element rect) into the
|
|
7
|
+
* `Page.captureScreenshot` clip + the logical dimensions/truncation flags the
|
|
8
|
+
* `ScreenshotResult` reports. No chrome.* calls — just arithmetic.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.MAX_CAPTURE_PX = void 0;
|
|
12
|
+
exports.planScreenshot = planScreenshot;
|
|
13
|
+
/**
|
|
14
|
+
* Practical single-capture height ceiling. Skia/CDP cannot encode arbitrarily
|
|
15
|
+
* tall images; beyond this we clamp the clip and flag `truncated`.
|
|
16
|
+
*/
|
|
17
|
+
exports.MAX_CAPTURE_PX = 16384;
|
|
18
|
+
/**
|
|
19
|
+
* Plan a capture. Element clip wins over fullPage; fullPage wins over the plain
|
|
20
|
+
* viewport capture. Heights are clamped to MAX_CAPTURE_PX with `truncated` set.
|
|
21
|
+
*/
|
|
22
|
+
function planScreenshot(dims, opts = {}) {
|
|
23
|
+
if (opts.element) {
|
|
24
|
+
const realH = Math.max(1, Math.round(opts.element.h));
|
|
25
|
+
const clipH = Math.min(opts.element.h, exports.MAX_CAPTURE_PX);
|
|
26
|
+
return {
|
|
27
|
+
clip: { x: opts.element.x, y: opts.element.y, width: opts.element.w, height: clipH, scale: 1 },
|
|
28
|
+
captureBeyondViewport: true,
|
|
29
|
+
width: Math.max(1, Math.round(opts.element.w)),
|
|
30
|
+
height: Math.min(realH, exports.MAX_CAPTURE_PX),
|
|
31
|
+
truncated: realH > exports.MAX_CAPTURE_PX,
|
|
32
|
+
fullHeight: realH,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
if (opts.fullPage) {
|
|
36
|
+
const clipH = Math.min(dims.fullH, exports.MAX_CAPTURE_PX);
|
|
37
|
+
return {
|
|
38
|
+
clip: { x: 0, y: 0, width: dims.fullW, height: clipH, scale: 1 },
|
|
39
|
+
captureBeyondViewport: true,
|
|
40
|
+
width: dims.fullW,
|
|
41
|
+
height: clipH,
|
|
42
|
+
truncated: dims.fullH > clipH,
|
|
43
|
+
fullHeight: dims.fullH,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
// Plain viewport: no clip, capture what's visible.
|
|
47
|
+
return {
|
|
48
|
+
captureBeyondViewport: false,
|
|
49
|
+
width: dims.w,
|
|
50
|
+
height: dims.h,
|
|
51
|
+
truncated: false,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=screenshot.js.map
|
|
@@ -54,7 +54,9 @@ export declare class CdpExecutor implements Executor {
|
|
|
54
54
|
private guard;
|
|
55
55
|
tabsList(): Promise<TabInfo[]>;
|
|
56
56
|
tabSelect(tabId: TabId): Promise<TabInfo>;
|
|
57
|
-
tabNew(url?: string
|
|
57
|
+
tabNew(url?: string, opts?: {
|
|
58
|
+
active?: boolean;
|
|
59
|
+
}): Promise<TabInfo>;
|
|
58
60
|
tabClose(tabId: TabId): Promise<{
|
|
59
61
|
closed: true;
|
|
60
62
|
tabId: TabId;
|
|
@@ -288,11 +288,13 @@ class CdpExecutor {
|
|
|
288
288
|
await p.bringToFront().catch(() => undefined);
|
|
289
289
|
return { tabId, url: p.url(), title: await p.title().catch(() => ''), active: true, index: 0 };
|
|
290
290
|
}
|
|
291
|
-
async tabNew(url) {
|
|
291
|
+
async tabNew(url, opts) {
|
|
292
292
|
const ctx = await this.getContext();
|
|
293
293
|
const p = await ctx.newPage();
|
|
294
294
|
if (url)
|
|
295
295
|
await p.goto(url, { waitUntil: 'load' }).catch(() => undefined);
|
|
296
|
+
if (opts?.active !== false)
|
|
297
|
+
await p.bringToFront().catch(() => undefined);
|
|
296
298
|
return { tabId: this.idFor(p), url: p.url(), title: await p.title().catch(() => ''), active: true, index: 0 };
|
|
297
299
|
}
|
|
298
300
|
async tabClose(tabId) {
|
|
@@ -20,7 +20,9 @@ export declare class ExtensionExecutor implements Executor {
|
|
|
20
20
|
dispose(): Promise<void>;
|
|
21
21
|
tabsList(): Promise<TabInfo[]>;
|
|
22
22
|
tabSelect(tabId: TabId): Promise<TabInfo>;
|
|
23
|
-
tabNew(url?: string
|
|
23
|
+
tabNew(url?: string, opts?: {
|
|
24
|
+
active?: boolean;
|
|
25
|
+
}): Promise<TabInfo>;
|
|
24
26
|
tabClose(tabId: TabId): Promise<{
|
|
25
27
|
closed: true;
|
|
26
28
|
tabId: TabId;
|
|
@@ -61,8 +61,8 @@ class ExtensionExecutor {
|
|
|
61
61
|
async tabSelect(tabId) {
|
|
62
62
|
return (await this.send('tab_select', {}, { tabId }));
|
|
63
63
|
}
|
|
64
|
-
async tabNew(url) {
|
|
65
|
-
return (await this.send('tab_new', { url }));
|
|
64
|
+
async tabNew(url, opts) {
|
|
65
|
+
return (await this.send('tab_new', { url, active: opts?.active }));
|
|
66
66
|
}
|
|
67
67
|
async tabClose(tabId) {
|
|
68
68
|
return (await this.send('tab_close', {}, { tabId }));
|
|
@@ -27,7 +27,9 @@ export declare class StubExecutor implements Executor {
|
|
|
27
27
|
dispose(): Promise<void>;
|
|
28
28
|
tabsList(): Promise<TabInfo[]>;
|
|
29
29
|
tabSelect(tabId: TabId): Promise<TabInfo>;
|
|
30
|
-
tabNew(url?: string
|
|
30
|
+
tabNew(url?: string, _opts?: {
|
|
31
|
+
active?: boolean;
|
|
32
|
+
}): Promise<TabInfo>;
|
|
31
33
|
tabClose(tabId: TabId): Promise<{
|
|
32
34
|
closed: true;
|
|
33
35
|
tabId: TabId;
|
|
@@ -144,7 +144,10 @@ export interface Executor {
|
|
|
144
144
|
dispose(): Promise<void>;
|
|
145
145
|
tabsList(): Promise<TabInfo[]>;
|
|
146
146
|
tabSelect(tabId: TabId): Promise<TabInfo>;
|
|
147
|
-
|
|
147
|
+
/** Open a tab. `active` (default true) focuses it; pass false to open in the background. */
|
|
148
|
+
tabNew(url?: string, opts?: {
|
|
149
|
+
active?: boolean;
|
|
150
|
+
}): Promise<TabInfo>;
|
|
148
151
|
tabClose(tabId: TabId): Promise<{
|
|
149
152
|
closed: true;
|
|
150
153
|
tabId: TabId;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/mcp/batch.ts — the `batch` fan-out tool: run many tool calls in one
|
|
3
|
+
* request, in parallel (default) or serially.
|
|
4
|
+
*
|
|
5
|
+
* Pure server-side composition: every sub-op is routed back through the same
|
|
6
|
+
* `dispatchToolCall` firewall, so it inherits the policy gate (server AND
|
|
7
|
+
* extension), the rate limiter, the executor-ready guard, and never-throw error
|
|
8
|
+
* rendering. There is no security bypass — a batch of N tool calls is exactly N
|
|
9
|
+
* ordinary tool calls that happen to be issued together.
|
|
10
|
+
*
|
|
11
|
+
* Concurrency safety: in parallel mode a tab-scoped sub-op that omits `tabId`
|
|
12
|
+
* would fall back to the shared "active tab" pointer, which races under
|
|
13
|
+
* concurrency (see docs/BLUEPRINT.md and the SW executor's active-tab default).
|
|
14
|
+
* So parallel mode REQUIRES an explicit `tabId` on tab-scoped ops; the op is
|
|
15
|
+
* rejected (as its own isError result) rather than silently mis-routed.
|
|
16
|
+
*/
|
|
17
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
18
|
+
/** Routes a single tool call through the never-throw firewall. */
|
|
19
|
+
export type DispatchFn = (name: string, args: unknown) => Promise<CallToolResult>;
|
|
20
|
+
export interface BatchDeps {
|
|
21
|
+
dispatch: DispatchFn;
|
|
22
|
+
/** True for tools that act on a specific tab and default to the active tab
|
|
23
|
+
* when `tabId` is omitted — those need an explicit `tabId` in parallel mode. */
|
|
24
|
+
requiresExplicitTab: (tool: string) => boolean;
|
|
25
|
+
}
|
|
26
|
+
export declare function runBatch(rawArgs: unknown, deps: BatchDeps): Promise<CallToolResult>;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* src/mcp/batch.ts — the `batch` fan-out tool: run many tool calls in one
|
|
4
|
+
* request, in parallel (default) or serially.
|
|
5
|
+
*
|
|
6
|
+
* Pure server-side composition: every sub-op is routed back through the same
|
|
7
|
+
* `dispatchToolCall` firewall, so it inherits the policy gate (server AND
|
|
8
|
+
* extension), the rate limiter, the executor-ready guard, and never-throw error
|
|
9
|
+
* rendering. There is no security bypass — a batch of N tool calls is exactly N
|
|
10
|
+
* ordinary tool calls that happen to be issued together.
|
|
11
|
+
*
|
|
12
|
+
* Concurrency safety: in parallel mode a tab-scoped sub-op that omits `tabId`
|
|
13
|
+
* would fall back to the shared "active tab" pointer, which races under
|
|
14
|
+
* concurrency (see docs/BLUEPRINT.md and the SW executor's active-tab default).
|
|
15
|
+
* So parallel mode REQUIRES an explicit `tabId` on tab-scoped ops; the op is
|
|
16
|
+
* rejected (as its own isError result) rather than silently mis-routed.
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.runBatch = runBatch;
|
|
20
|
+
const envelopes_1 = require("./envelopes");
|
|
21
|
+
const validators_1 = require("./validators");
|
|
22
|
+
/** Hard cap on operations per batch — bounds memory (results accumulate) and blast radius. */
|
|
23
|
+
const MAX_OPS = 50;
|
|
24
|
+
const DEFAULT_CONCURRENCY = 6;
|
|
25
|
+
const MAX_CONCURRENCY = 16;
|
|
26
|
+
/** Validate the `ops` envelope. Structural problems throw (the whole batch is
|
|
27
|
+
* malformed); per-op semantic problems are handled later as per-op errors. */
|
|
28
|
+
function parseOps(raw) {
|
|
29
|
+
if (!Array.isArray(raw))
|
|
30
|
+
throw new validators_1.McpToolError('"ops" must be an array of { tool, args } objects');
|
|
31
|
+
if (raw.length === 0)
|
|
32
|
+
throw new validators_1.McpToolError('"ops" must contain at least one operation');
|
|
33
|
+
if (raw.length > MAX_OPS)
|
|
34
|
+
throw new validators_1.McpToolError(`"ops" has ${raw.length} operations; the max is ${MAX_OPS}`);
|
|
35
|
+
return raw.map((o, i) => {
|
|
36
|
+
if (typeof o !== 'object' || o === null || Array.isArray(o)) {
|
|
37
|
+
throw new validators_1.McpToolError(`ops[${i}] must be an object with a "tool" and optional "args"`);
|
|
38
|
+
}
|
|
39
|
+
const rec = o;
|
|
40
|
+
if (typeof rec.tool !== 'string' || rec.tool.length === 0) {
|
|
41
|
+
throw new validators_1.McpToolError(`ops[${i}].tool must be a non-empty string`);
|
|
42
|
+
}
|
|
43
|
+
if (rec.args !== undefined && (typeof rec.args !== 'object' || rec.args === null || Array.isArray(rec.args))) {
|
|
44
|
+
throw new validators_1.McpToolError(`ops[${i}].args must be an object`);
|
|
45
|
+
}
|
|
46
|
+
return { tool: rec.tool, args: rec.args ?? {} };
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/** Map with bounded concurrency. `fn` never throws (dispatch is the firewall). */
|
|
50
|
+
async function mapLimit(items, limit, fn) {
|
|
51
|
+
const out = new Array(items.length);
|
|
52
|
+
let next = 0;
|
|
53
|
+
const worker = async () => {
|
|
54
|
+
for (;;) {
|
|
55
|
+
const i = next++;
|
|
56
|
+
if (i >= items.length)
|
|
57
|
+
return;
|
|
58
|
+
out[i] = await fn(items[i], i);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
async function runBatch(rawArgs, deps) {
|
|
65
|
+
const a = (0, validators_1.asArgs)(rawArgs);
|
|
66
|
+
const ops = parseOps(a.ops);
|
|
67
|
+
const mode = ((0, validators_1.optionalString)(a, 'mode') ?? 'parallel');
|
|
68
|
+
if (mode !== 'parallel' && mode !== 'serial') {
|
|
69
|
+
throw new validators_1.McpToolError('"mode" must be "parallel" or "serial"');
|
|
70
|
+
}
|
|
71
|
+
const stopOnError = (0, validators_1.optionalBoolean)(a, 'stopOnError') ?? false;
|
|
72
|
+
const concurrency = (0, validators_1.optionalNumber)(a, 'maxConcurrency', { min: 1, max: MAX_CONCURRENCY }) ?? DEFAULT_CONCURRENCY;
|
|
73
|
+
/** Run one op through the firewall, after the per-op guards. Never throws. */
|
|
74
|
+
const runOne = async (op) => {
|
|
75
|
+
if (op.tool === 'batch')
|
|
76
|
+
return (0, envelopes_1.errorResult)('batch cannot be nested inside batch');
|
|
77
|
+
if (mode === 'parallel' && deps.requiresExplicitTab(op.tool) && op.args.tabId == null) {
|
|
78
|
+
return (0, envelopes_1.errorResult)(`"${op.tool}" needs an explicit "tabId" in a parallel batch — the active-tab default is unsafe under concurrency (use serial mode, or pass tabId)`);
|
|
79
|
+
}
|
|
80
|
+
// In a parallel batch, default new tabs to the background so N concurrent
|
|
81
|
+
// opens don't fight over window focus (a single tab_new still focuses).
|
|
82
|
+
let args = op.args;
|
|
83
|
+
if (mode === 'parallel' && op.tool === 'tab_new' && args.active === undefined) {
|
|
84
|
+
args = { ...args, active: false };
|
|
85
|
+
}
|
|
86
|
+
return deps.dispatch(op.tool, args);
|
|
87
|
+
};
|
|
88
|
+
let outcomes;
|
|
89
|
+
if (mode === 'serial') {
|
|
90
|
+
outcomes = ops.map(() => ({ status: 'skipped' }));
|
|
91
|
+
for (let i = 0; i < ops.length; i++) {
|
|
92
|
+
const result = await runOne(ops[i]);
|
|
93
|
+
outcomes[i] = { status: result.isError ? 'error' : 'ok', result };
|
|
94
|
+
if (stopOnError && result.isError)
|
|
95
|
+
break; // leave the rest 'skipped'
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
const results = await mapLimit(ops, concurrency, (op) => runOne(op));
|
|
100
|
+
outcomes = results.map((result) => ({ status: result.isError ? 'error' : 'ok', result }));
|
|
101
|
+
}
|
|
102
|
+
return renderBatch(ops, outcomes, mode);
|
|
103
|
+
}
|
|
104
|
+
/** Compose the per-op outcomes into one MCP result: a JSON summary block first,
|
|
105
|
+
* then each executed op's own content blocks (text/images flow through intact). */
|
|
106
|
+
function renderBatch(ops, outcomes, mode) {
|
|
107
|
+
const summary = outcomes.map((o, i) => ({ index: i, tool: ops[i].tool, status: o.status }));
|
|
108
|
+
const counts = {
|
|
109
|
+
total: ops.length,
|
|
110
|
+
ok: summary.filter((s) => s.status === 'ok').length,
|
|
111
|
+
error: summary.filter((s) => s.status === 'error').length,
|
|
112
|
+
skipped: summary.filter((s) => s.status === 'skipped').length,
|
|
113
|
+
};
|
|
114
|
+
const content = [
|
|
115
|
+
{ type: 'text', text: JSON.stringify({ batch: { mode, ...counts }, results: summary }, null, 2) },
|
|
116
|
+
];
|
|
117
|
+
for (let i = 0; i < outcomes.length; i++) {
|
|
118
|
+
const o = outcomes[i];
|
|
119
|
+
if (!o.result)
|
|
120
|
+
continue; // skipped ops carry no payload
|
|
121
|
+
content.push({ type: 'text', text: `--- op ${i} (${ops[i].tool}) ${o.status} ---` });
|
|
122
|
+
for (const block of o.result.content)
|
|
123
|
+
content.push(block);
|
|
124
|
+
}
|
|
125
|
+
// The batch ran successfully even if some ops failed; only flag isError when
|
|
126
|
+
// nothing succeeded, so a host sees partial success as success.
|
|
127
|
+
const isError = ops.length > 0 && counts.ok === 0;
|
|
128
|
+
return isError ? { content, isError: true } : { content };
|
|
129
|
+
}
|
|
130
|
+
//# sourceMappingURL=batch.js.map
|
package/dist/src/mcp/tools.js
CHANGED
|
@@ -22,6 +22,7 @@ const types_1 = require("../executor/types");
|
|
|
22
22
|
const manager_1 = require("../executor/manager");
|
|
23
23
|
const policy_1 = require("../security/policy");
|
|
24
24
|
const envelopes_1 = require("./envelopes");
|
|
25
|
+
const batch_1 = require("./batch");
|
|
25
26
|
const helpers_1 = require("./helpers");
|
|
26
27
|
const validators_1 = require("./validators");
|
|
27
28
|
const TARGET_PROPS = {
|
|
@@ -37,9 +38,9 @@ const obj = (properties, required = []) => ({
|
|
|
37
38
|
exports.TOOL_DEFINITIONS = [
|
|
38
39
|
{ name: 'tabs_list', description: 'List open browser tabs.', inputSchema: obj({}) },
|
|
39
40
|
{ name: 'tab_select', description: 'Make a tab active by tabId.', inputSchema: obj({ tabId: { type: 'string' } }, ['tabId']) },
|
|
40
|
-
{ name: 'tab_new', description: 'Open a
|
|
41
|
+
{ name: 'tab_new', description: 'Open a NEW tab (optionally at a URL) and focus it. Prefer this over `navigate` when the user says "open"/"go to" a site — `navigate` REPLACES the current tab. Pass active:false to open in the background (used by parallel batches).', inputSchema: obj({ url: { type: 'string' }, active: { type: 'boolean' } }) },
|
|
41
42
|
{ name: 'tab_close', description: 'Close a tab by tabId.', inputSchema: obj({ tabId: { type: 'string' } }, ['tabId']) },
|
|
42
|
-
{ name: 'navigate', description: 'Navigate the active
|
|
43
|
+
{ name: 'navigate', description: 'Navigate a tab to a URL, REPLACING its current page. Acts on the active tab unless tabId is given — to open a site without losing the current page, use `tab_new` instead.', inputSchema: obj({ url: { type: 'string' }, tabId: { type: 'string' }, waitUntil: { type: 'string', enum: ['load', 'domcontentloaded', 'networkidle'] } }, ['url']) },
|
|
43
44
|
{ name: 'back', description: 'Go back in history.', inputSchema: obj({ tabId: { type: 'string' } }) },
|
|
44
45
|
{ name: 'forward', description: 'Go forward in history.', inputSchema: obj({ tabId: { type: 'string' } }) },
|
|
45
46
|
{ name: 'reload', description: 'Reload the active (or given) tab.', inputSchema: obj({ tabId: { type: 'string' }, waitUntil: { type: 'string', enum: ['load', 'domcontentloaded', 'networkidle'] } }) },
|
|
@@ -63,6 +64,20 @@ exports.TOOL_DEFINITIONS = [
|
|
|
63
64
|
{ name: 'download_file', description: 'Download a file by URL or from a link element.', inputSchema: obj({ url: { type: 'string' }, ...TARGET_PROPS, suggestedName: { type: 'string' }, tabId: { type: 'string' } }) },
|
|
64
65
|
{ name: 'upload_file', description: 'Set local file(s) on a file <input> (target by selector or ref) — uploads without the OS dialog. Requires --enable-uploads. `files` are absolute local paths.', inputSchema: obj({ ...TARGET_PROPS, files: { type: 'array', items: { type: 'string' } }, tabId: { type: 'string' } }, ['files']) },
|
|
65
66
|
{ name: 'chrome_status', description: 'Report backend/session status.', inputSchema: obj({}) },
|
|
67
|
+
{
|
|
68
|
+
name: 'batch',
|
|
69
|
+
description: 'Run multiple tool calls in one request — parallel (default) or serial. Each op is { tool, args } and goes through the same policy gate, rate limit, and error handling as a direct call. In parallel mode, tab-scoped ops MUST pass an explicit tabId (the active-tab default is unsafe under concurrency). Use to drive several tabs at once (e.g. open tabs, then batch get_text across them). Cannot be nested.',
|
|
70
|
+
inputSchema: obj({
|
|
71
|
+
ops: {
|
|
72
|
+
type: 'array',
|
|
73
|
+
description: 'Operations to run; each is a tool name + its args.',
|
|
74
|
+
items: obj({ tool: { type: 'string' }, args: { type: 'object' } }, ['tool']),
|
|
75
|
+
},
|
|
76
|
+
mode: { type: 'string', enum: ['parallel', 'serial'], description: 'Default "parallel".' },
|
|
77
|
+
stopOnError: { type: 'boolean', description: 'Serial mode only: stop after the first failing op (the rest are skipped).' },
|
|
78
|
+
maxConcurrency: { type: 'number', description: 'Parallel mode: max ops in flight at once (default 6).' },
|
|
79
|
+
}, ['ops']),
|
|
80
|
+
},
|
|
66
81
|
];
|
|
67
82
|
/** Resolve the URL the policy should be evaluated against (the active tab). */
|
|
68
83
|
async function activeUrl(ex) {
|
|
@@ -81,6 +96,14 @@ async function gate(ctx, method, urlOverride) {
|
|
|
81
96
|
}
|
|
82
97
|
const tabId = (args) => (0, validators_1.optionalString)(args, 'tabId');
|
|
83
98
|
const waitUntil = (args) => (0, validators_1.optionalString)(args, 'waitUntil');
|
|
99
|
+
/** Tools that don't act on a single tab (so `tabId` is irrelevant) — exempt from
|
|
100
|
+
* the parallel-batch explicit-tabId requirement. Everything else falls back to
|
|
101
|
+
* the active tab when `tabId` is omitted, which races under concurrency. */
|
|
102
|
+
const PARALLEL_TAB_EXEMPT = new Set(['tabs_list', 'tab_new', 'chrome_status', 'batch']);
|
|
103
|
+
/** A known tool that operates on a specific tab — needs an explicit tabId in a parallel batch. */
|
|
104
|
+
function requiresExplicitTab(tool) {
|
|
105
|
+
return tool in exports.TOOL_HANDLERS && !PARALLEL_TAB_EXEMPT.has(tool);
|
|
106
|
+
}
|
|
84
107
|
exports.TOOL_HANDLERS = {
|
|
85
108
|
tabs_list: async (_a, ctx) => (0, envelopes_1.jsonResult)(await ctx.ex.tabsList()),
|
|
86
109
|
tab_select: async (a, ctx) => {
|
|
@@ -89,7 +112,7 @@ exports.TOOL_HANDLERS = {
|
|
|
89
112
|
},
|
|
90
113
|
tab_new: async (a, ctx) => {
|
|
91
114
|
await gate(ctx, 'tab_new');
|
|
92
|
-
return (0, envelopes_1.jsonResult)(await ctx.ex.tabNew((0, validators_1.optionalString)(a, 'url')));
|
|
115
|
+
return (0, envelopes_1.jsonResult)(await ctx.ex.tabNew((0, validators_1.optionalString)(a, 'url'), { active: (0, validators_1.optionalBoolean)(a, 'active') }));
|
|
93
116
|
},
|
|
94
117
|
tab_close: async (a, ctx) => {
|
|
95
118
|
await gate(ctx, 'tab_close');
|
|
@@ -285,6 +308,9 @@ exports.TOOL_HANDLERS = {
|
|
|
285
308
|
return (0, envelopes_1.jsonResult)(await ctx.ex.uploadFile(t, files, { tabId: tabId(a) }));
|
|
286
309
|
},
|
|
287
310
|
chrome_status: async (_a, ctx) => (0, envelopes_1.jsonResult)(ctx.ex.status()),
|
|
311
|
+
// Fan-out: each sub-op is routed back through `dispatchToolCall`, so it gets
|
|
312
|
+
// the same policy gate, rate limit, and never-throw handling as a direct call.
|
|
313
|
+
batch: async (a) => (0, batch_1.runBatch)(a, { dispatch: dispatchToolCall, requiresExplicitTab }),
|
|
288
314
|
};
|
|
289
315
|
// ---------------------------------------------------------------------------
|
|
290
316
|
// Dispatch (never-throw firewall)
|
package/docs/BLUEPRINT.md
CHANGED
|
@@ -588,9 +588,14 @@ Build: `files` whitelist incl. `extension-dist/`, `--print-pairing`/`--print-ext
|
|
|
588
588
|
- **Native-messaging trampoline install step** is the one manual setup beyond `npx`; the manual file-path paste is the no-native fallback. Smoother one-click pairing is a v1.1 polish.
|
|
589
589
|
- **`networkidle`** is approximated by a bounded idle-window poll (no native CDP event); documented as best-effort, never able to wedge a call.
|
|
590
590
|
- **Local-code-execution attacker** who can already read the user's 0600 files has root-equivalent access to the user session; the token cannot defend against an attacker who already owns the filesystem. The policy allowlist still blocks blind exfil to arbitrary domains.
|
|
591
|
-
- **`captureBeyondViewport` very-tall pages**:
|
|
591
|
+
- **`captureBeyondViewport` very-tall pages**: full-page capture now ships (extension `screenshot` uses `chrome.debugger Page.captureScreenshot` with a content-box clip); only pages taller than the ~16384px skia ceiling are clamped + `truncated`-flagged. [RESOLVED — v0.5.0]
|
|
592
|
+
|
|
593
|
+
**Resolved in v0.5.0 (safe multi-tab concurrency):**
|
|
594
|
+
- **Screenshot active-tab race (H1):** the extension captured via `captureVisibleTab`, which had to activate the target tab — concurrent captures stole focus and could grab the wrong tab. Now `chrome.debugger Page.captureScreenshot` captures a specific tab without activating it.
|
|
595
|
+
- **Active-tab default under concurrency (H2):** the `batch` fan-out tool requires an explicit `tabId` on tab-scoped ops in `parallel` mode (rejected rather than mis-routed to whatever tab is frontmost).
|
|
596
|
+
- **Per-tab debugger collisions + `tab_new` race (H3/H4):** a `KeyedMutex` serializes `chrome.debugger` attach/detach per tab and the `tab_new` blank-tab claim; different tabs still run in parallel.
|
|
597
|
+
- Per-cmd `tabId` addressing is on every wire method, so multi-tab works without a backend pool. A true multi-*session* `Executor` pool remains out of scope.
|
|
592
598
|
|
|
593
599
|
**Genuinely open (decide before v1.1):**
|
|
594
|
-
- Multi-tab/multi-session concurrency (single global Executor + single attached tab today): does an agent need N tabs driven simultaneously? That breaks the singleton and requires per-cmd `tabId` everywhere on the wire.
|
|
595
600
|
- Web Store path: requires a `chrome.scripting`-only mode (no `chrome.debugger`) — a second executor backend behind the same interface.
|
|
596
601
|
- Whether `download` should ever use the extension `chrome.downloads` path (user Downloads dir) as an explicit opt-in, or remain server-fetch-only forever.
|
|
@@ -359,6 +359,62 @@
|
|
|
359
359
|
return { url: location.href, title: document.title, nodes, truncated: els.length > nodes.length };
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
+
// shared/screenshot.ts
|
|
363
|
+
var MAX_CAPTURE_PX = 16384;
|
|
364
|
+
function planScreenshot(dims, opts = {}) {
|
|
365
|
+
if (opts.element) {
|
|
366
|
+
const realH = Math.max(1, Math.round(opts.element.h));
|
|
367
|
+
const clipH = Math.min(opts.element.h, MAX_CAPTURE_PX);
|
|
368
|
+
return {
|
|
369
|
+
clip: { x: opts.element.x, y: opts.element.y, width: opts.element.w, height: clipH, scale: 1 },
|
|
370
|
+
captureBeyondViewport: true,
|
|
371
|
+
width: Math.max(1, Math.round(opts.element.w)),
|
|
372
|
+
height: Math.min(realH, MAX_CAPTURE_PX),
|
|
373
|
+
truncated: realH > MAX_CAPTURE_PX,
|
|
374
|
+
fullHeight: realH
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
if (opts.fullPage) {
|
|
378
|
+
const clipH = Math.min(dims.fullH, MAX_CAPTURE_PX);
|
|
379
|
+
return {
|
|
380
|
+
clip: { x: 0, y: 0, width: dims.fullW, height: clipH, scale: 1 },
|
|
381
|
+
captureBeyondViewport: true,
|
|
382
|
+
width: dims.fullW,
|
|
383
|
+
height: clipH,
|
|
384
|
+
truncated: dims.fullH > clipH,
|
|
385
|
+
fullHeight: dims.fullH
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
return {
|
|
389
|
+
captureBeyondViewport: false,
|
|
390
|
+
width: dims.w,
|
|
391
|
+
height: dims.h,
|
|
392
|
+
truncated: false
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// shared/mutex.ts
|
|
397
|
+
function noop() {
|
|
398
|
+
}
|
|
399
|
+
var KeyedMutex = class {
|
|
400
|
+
tails = /* @__PURE__ */ new Map();
|
|
401
|
+
/** Run `fn` after all earlier holders of `key` settle. Resolves/rejects with fn's outcome. */
|
|
402
|
+
run(key, fn) {
|
|
403
|
+
const prev = this.tails.get(key) ?? Promise.resolve();
|
|
404
|
+
const result = prev.then(fn, fn);
|
|
405
|
+
const tail = result.then(noop, noop);
|
|
406
|
+
this.tails.set(key, tail);
|
|
407
|
+
void tail.then(() => {
|
|
408
|
+
if (this.tails.get(key) === tail) this.tails.delete(key);
|
|
409
|
+
});
|
|
410
|
+
return result;
|
|
411
|
+
}
|
|
412
|
+
/** Number of keys with an outstanding or queued holder (for tests/inspection). */
|
|
413
|
+
get size() {
|
|
414
|
+
return this.tails.size;
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
|
|
362
418
|
// extension/src/sw/executor.ts
|
|
363
419
|
var CmdError = class extends Error {
|
|
364
420
|
constructor(code, message) {
|
|
@@ -369,6 +425,8 @@
|
|
|
369
425
|
var SESSION = crypto.randomUUID();
|
|
370
426
|
var CONTENT_SCHEME = /^(https?|file):/i;
|
|
371
427
|
var delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
428
|
+
var locks = new KeyedMutex();
|
|
429
|
+
var claimedTabs = /* @__PURE__ */ new Set();
|
|
372
430
|
function mint(tabId) {
|
|
373
431
|
return `ext:${SESSION}:${tabId}`;
|
|
374
432
|
}
|
|
@@ -441,13 +499,15 @@
|
|
|
441
499
|
}
|
|
442
500
|
}
|
|
443
501
|
async function withDebugger(tabId, fn) {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
502
|
+
return locks.run(`dbg:${tabId}`, async () => {
|
|
503
|
+
const target = { tabId };
|
|
504
|
+
await chrome.debugger.attach(target, "1.3");
|
|
505
|
+
try {
|
|
506
|
+
return await fn(target);
|
|
507
|
+
} finally {
|
|
508
|
+
await chrome.debugger.detach(target).catch(() => void 0);
|
|
509
|
+
}
|
|
510
|
+
});
|
|
451
511
|
}
|
|
452
512
|
async function trustedType(tabId, selector, text, clear) {
|
|
453
513
|
const focused = await execInTab(
|
|
@@ -489,6 +549,72 @@
|
|
|
489
549
|
});
|
|
490
550
|
return true;
|
|
491
551
|
}
|
|
552
|
+
async function measurePage(tabId, selector) {
|
|
553
|
+
return execInTab(
|
|
554
|
+
tabId,
|
|
555
|
+
(sel) => {
|
|
556
|
+
const d = document.documentElement;
|
|
557
|
+
const dims = {
|
|
558
|
+
w: window.innerWidth,
|
|
559
|
+
h: window.innerHeight,
|
|
560
|
+
fullW: Math.max(d.scrollWidth, d.clientWidth),
|
|
561
|
+
fullH: Math.max(d.scrollHeight, d.clientHeight)
|
|
562
|
+
};
|
|
563
|
+
if (!sel) return { dims, element: null, missing: false };
|
|
564
|
+
const el = document.querySelector(sel);
|
|
565
|
+
if (!el) return { dims, element: null, missing: true };
|
|
566
|
+
el.scrollIntoView({ block: "center", inline: "center" });
|
|
567
|
+
const r = el.getBoundingClientRect();
|
|
568
|
+
return { dims, element: { x: r.left + window.scrollX, y: r.top + window.scrollY, w: r.width, h: r.height }, missing: false };
|
|
569
|
+
},
|
|
570
|
+
[selector ?? null]
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
async function screenshotViaDebugger(tabId, fullPage, selector) {
|
|
574
|
+
const measured = await measurePage(tabId, selector);
|
|
575
|
+
if (!measured) throw new CmdError("CDP_ERROR", "could not read page dimensions");
|
|
576
|
+
if (selector && measured.missing) throw new CmdError("SELECTOR_NOT_FOUND", `no element for selector: ${selector}`);
|
|
577
|
+
const plan = planScreenshot(measured.dims, { fullPage, element: measured.element });
|
|
578
|
+
const params = { format: "png", captureBeyondViewport: plan.captureBeyondViewport };
|
|
579
|
+
if (plan.clip) params.clip = plan.clip;
|
|
580
|
+
const data = await withDebugger(tabId, async (target) => {
|
|
581
|
+
const res = await chrome.debugger.sendCommand(target, "Page.captureScreenshot", params);
|
|
582
|
+
return res.data ?? "";
|
|
583
|
+
});
|
|
584
|
+
return {
|
|
585
|
+
dataBase64: data,
|
|
586
|
+
mimeType: "image/png",
|
|
587
|
+
width: plan.width,
|
|
588
|
+
height: plan.height,
|
|
589
|
+
truncated: plan.truncated,
|
|
590
|
+
fullHeight: plan.fullHeight
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
async function screenshotViaVisibleTab(tabId, fullPage) {
|
|
594
|
+
let t = await chrome.tabs.get(tabId);
|
|
595
|
+
if (!t.active) {
|
|
596
|
+
await chrome.tabs.update(tabId, { active: true });
|
|
597
|
+
await chrome.windows.update(t.windowId, { focused: true }).catch(() => void 0);
|
|
598
|
+
await delay(150);
|
|
599
|
+
t = await chrome.tabs.get(tabId);
|
|
600
|
+
}
|
|
601
|
+
const dims = await execInTab(
|
|
602
|
+
tabId,
|
|
603
|
+
() => ({ w: window.innerWidth, h: window.innerHeight, full: document.documentElement.scrollHeight }),
|
|
604
|
+
[]
|
|
605
|
+
);
|
|
606
|
+
const dataUrl = await chrome.tabs.captureVisibleTab(t.windowId, { format: "png" });
|
|
607
|
+
const viewportH = dims?.h ?? 0;
|
|
608
|
+
const fullH = dims?.full ?? viewportH;
|
|
609
|
+
return {
|
|
610
|
+
dataBase64: dataUrl.split(",")[1] ?? "",
|
|
611
|
+
mimeType: "image/png",
|
|
612
|
+
width: dims?.w ?? 0,
|
|
613
|
+
height: viewportH,
|
|
614
|
+
truncated: fullPage && fullH > viewportH,
|
|
615
|
+
fullHeight: fullPage ? fullH : void 0
|
|
616
|
+
};
|
|
617
|
+
}
|
|
492
618
|
async function tabInfo(tab, index = 0) {
|
|
493
619
|
return {
|
|
494
620
|
tabId: mint(tab.id ?? -1),
|
|
@@ -542,19 +668,34 @@
|
|
|
542
668
|
}
|
|
543
669
|
case "tab_new": {
|
|
544
670
|
const url = typeof cmd.params.url === "string" ? cmd.params.url : void 0;
|
|
671
|
+
const active = cmd.params.active !== false;
|
|
545
672
|
const BLANK = /^(about:blank|chrome:\/\/newtab|chrome:\/\/new-tab-page|edge:\/\/newtab)/i;
|
|
546
|
-
const
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
673
|
+
const claim = await locks.run("tab_new", async () => {
|
|
674
|
+
const tabs = await chrome.tabs.query({});
|
|
675
|
+
const present = new Set(tabs.map((t) => t.id).filter((id) => id !== void 0));
|
|
676
|
+
for (const id of claimedTabs) if (!present.has(id)) claimedTabs.delete(id);
|
|
677
|
+
const blank = tabs.find(
|
|
678
|
+
(t) => t.id !== void 0 && !claimedTabs.has(t.id) && (BLANK.test(t.url ?? "") || (t.url ?? "") === "" || t.pendingUrl === "about:blank")
|
|
679
|
+
);
|
|
680
|
+
if (blank?.id !== void 0) {
|
|
681
|
+
claimedTabs.add(blank.id);
|
|
682
|
+
return { id: blank.id, reused: true, needsNav: url !== void 0 };
|
|
553
683
|
}
|
|
554
|
-
|
|
684
|
+
const created = await chrome.tabs.create({ url, active: false });
|
|
685
|
+
if (created.id === void 0) throw new CmdError("TARGET_GONE", "failed to create a tab");
|
|
686
|
+
claimedTabs.add(created.id);
|
|
687
|
+
return { id: created.id, reused: false, needsNav: false };
|
|
688
|
+
});
|
|
689
|
+
if (claim.needsNav) {
|
|
690
|
+
await chrome.tabs.update(claim.id, { url });
|
|
691
|
+
await waitComplete(claim.id);
|
|
692
|
+
}
|
|
693
|
+
if (active) {
|
|
694
|
+
const t = await chrome.tabs.get(claim.id);
|
|
695
|
+
await chrome.tabs.update(claim.id, { active: true }).catch(() => void 0);
|
|
696
|
+
await chrome.windows.update(t.windowId, { focused: true }).catch(() => void 0);
|
|
555
697
|
}
|
|
556
|
-
|
|
557
|
-
return { ...await tabInfo(t), reused: false };
|
|
698
|
+
return { ...await tabInfo(await chrome.tabs.get(claim.id)), reused: claim.reused };
|
|
558
699
|
}
|
|
559
700
|
case "tab_close": {
|
|
560
701
|
const id = parseTabId(String(cmd.tabId));
|
|
@@ -809,34 +950,21 @@
|
|
|
809
950
|
);
|
|
810
951
|
return { ok: true };
|
|
811
952
|
}
|
|
812
|
-
// -- screenshot
|
|
953
|
+
// -- screenshot --
|
|
954
|
+
// Primary path: chrome.debugger Page.captureScreenshot, which captures a
|
|
955
|
+
// SPECIFIC tab WITHOUT activating it (no focus-stealing → safe under
|
|
956
|
+
// concurrent batches) and supports true full-page + element capture.
|
|
957
|
+
// Falls back to captureVisibleTab only if the debugger can't attach.
|
|
813
958
|
case "screenshot": {
|
|
814
959
|
const id = await targetTab(cmd);
|
|
815
|
-
let t = await chrome.tabs.get(id);
|
|
816
|
-
if (!t.active) {
|
|
817
|
-
await chrome.tabs.update(id, { active: true });
|
|
818
|
-
await chrome.windows.update(t.windowId, { focused: true }).catch(() => void 0);
|
|
819
|
-
await delay(150);
|
|
820
|
-
t = await chrome.tabs.get(id);
|
|
821
|
-
}
|
|
822
|
-
const dims = await execInTab(
|
|
823
|
-
id,
|
|
824
|
-
() => ({ w: window.innerWidth, h: window.innerHeight, full: document.documentElement.scrollHeight }),
|
|
825
|
-
[]
|
|
826
|
-
);
|
|
827
|
-
const dataUrl = await chrome.tabs.captureVisibleTab(t.windowId, { format: "png" });
|
|
828
960
|
const fullPage = cmd.params.fullPage === true;
|
|
829
|
-
const
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
// The scripting backend can only capture the viewport; flag when a fullPage was asked but clipped.
|
|
837
|
-
truncated: fullPage && fullH > viewportH,
|
|
838
|
-
fullHeight: fullPage ? fullH : void 0
|
|
839
|
-
};
|
|
961
|
+
const selector = selectorOf(cmd);
|
|
962
|
+
try {
|
|
963
|
+
return await screenshotViaDebugger(id, fullPage, selector);
|
|
964
|
+
} catch (err) {
|
|
965
|
+
if (err instanceof CmdError && err.code === "SELECTOR_NOT_FOUND") throw err;
|
|
966
|
+
return await screenshotViaVisibleTab(id, fullPage);
|
|
967
|
+
}
|
|
840
968
|
}
|
|
841
969
|
// -- eval (MAIN world; may be blocked by strict page CSP) --
|
|
842
970
|
case "eval": {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Chrome MCP Bridge",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.5.0",
|
|
5
5
|
"description": "Lets a local chrome-mcp server drive this browser. Pair it with the server's handshake token.",
|
|
6
6
|
"minimum_chrome_version": "116",
|
|
7
7
|
"background": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mehmoodqureshi/chrome-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Drive a real Chrome browser over MCP. A stdio MCP server (CLI) plus an MV3 extension, behind one pluggable Executor (extension via chrome.scripting, or a Playwright CDP fallback).",
|
|
5
5
|
"author": "Mehmood Ur Rehman Qureshi",
|
|
6
6
|
"license": "MIT",
|