@mehmoodqureshi/chrome-mcp 0.9.7 → 0.9.8
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/dist/shared/fill-form.d.ts +41 -0
- package/dist/shared/fill-form.js +55 -0
- package/dist/shared/policy.js +1 -0
- package/dist/shared/protocol.d.ts +29 -4
- package/dist/shared/protocol.js +12 -4
- package/dist/src/bridge/connection.d.ts +8 -0
- package/dist/src/bridge/connection.js +10 -2
- package/dist/src/bridge/server.d.ts +3 -0
- package/dist/src/bridge/server.js +8 -0
- package/dist/src/executor/extension-executor.d.ts +6 -1
- package/dist/src/executor/extension-executor.js +15 -0
- package/dist/src/executor/types.d.ts +13 -1
- package/dist/src/mcp/helpers.d.ts +8 -2
- package/dist/src/mcp/helpers.js +22 -12
- package/extension-dist/background.js +56 -3
- package/extension-dist/manifest.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sequencing behind a batched `fill_form`.
|
|
3
|
+
*
|
|
4
|
+
* Batching a form into ONE wire command removes a round-trip per field, but it
|
|
5
|
+
* also removes the router's per-command policy gate: the router evaluates once,
|
|
6
|
+
* before the first field, and then N writes happen with nothing re-checking
|
|
7
|
+
* where they land. That matters because a field's input handler — or a
|
|
8
|
+
* checkbox's click — can navigate the tab, and the values still to be written
|
|
9
|
+
* are exactly the sensitive ones (passwords, personal data). Typing them into
|
|
10
|
+
* whatever page replaced the allowlisted one would be a silent exfiltration.
|
|
11
|
+
*
|
|
12
|
+
* So the gate moves in here and runs per field, against the tab's CURRENT url,
|
|
13
|
+
* reproducing what N separate commands would have enforced. This lives in
|
|
14
|
+
* `shared/` for the same reason `evaluatePolicy` does: it is a security
|
|
15
|
+
* decision, so it belongs where both ends and the test suite can reach it
|
|
16
|
+
* (`extension/` is outside the tsconfig the tests compile).
|
|
17
|
+
*/
|
|
18
|
+
import type { FillFieldOp, FillFormWireResult, WirePolicy } from './protocol';
|
|
19
|
+
/** What the caller must supply; everything chrome-specific stays outside. */
|
|
20
|
+
export interface FillFormHooks {
|
|
21
|
+
/** The target tab's url right now — re-read before every field. */
|
|
22
|
+
currentUrl: () => Promise<string>;
|
|
23
|
+
/** The live wire policy, or null before a handshake has delivered one. */
|
|
24
|
+
policy: () => WirePolicy | null;
|
|
25
|
+
/** Perform one field write. Throws to fail the batch at this field. */
|
|
26
|
+
write: (op: FillFieldOp) => Promise<void>;
|
|
27
|
+
/** Map a thrown value to a wire error. */
|
|
28
|
+
toError: (err: unknown) => {
|
|
29
|
+
code: FillFormError['code'];
|
|
30
|
+
message: string;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
type FillFormError = NonNullable<FillFormWireResult['error']>;
|
|
34
|
+
/**
|
|
35
|
+
* Run `ops` in order, re-gating before each one, stopping at the first failure.
|
|
36
|
+
* Never throws: the failing field is reported in `error` and `filled` counts the
|
|
37
|
+
* writes that actually landed, so the server can tell the caller how far the
|
|
38
|
+
* form got rather than leaving it ambiguous.
|
|
39
|
+
*/
|
|
40
|
+
export declare function runFillFields(ops: readonly FillFieldOp[], hooks: FillFormHooks): Promise<FillFormWireResult>;
|
|
41
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The sequencing behind a batched `fill_form`.
|
|
4
|
+
*
|
|
5
|
+
* Batching a form into ONE wire command removes a round-trip per field, but it
|
|
6
|
+
* also removes the router's per-command policy gate: the router evaluates once,
|
|
7
|
+
* before the first field, and then N writes happen with nothing re-checking
|
|
8
|
+
* where they land. That matters because a field's input handler — or a
|
|
9
|
+
* checkbox's click — can navigate the tab, and the values still to be written
|
|
10
|
+
* are exactly the sensitive ones (passwords, personal data). Typing them into
|
|
11
|
+
* whatever page replaced the allowlisted one would be a silent exfiltration.
|
|
12
|
+
*
|
|
13
|
+
* So the gate moves in here and runs per field, against the tab's CURRENT url,
|
|
14
|
+
* reproducing what N separate commands would have enforced. This lives in
|
|
15
|
+
* `shared/` for the same reason `evaluatePolicy` does: it is a security
|
|
16
|
+
* decision, so it belongs where both ends and the test suite can reach it
|
|
17
|
+
* (`extension/` is outside the tsconfig the tests compile).
|
|
18
|
+
*/
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.runFillFields = runFillFields;
|
|
21
|
+
const policy_1 = require("./policy");
|
|
22
|
+
/**
|
|
23
|
+
* Run `ops` in order, re-gating before each one, stopping at the first failure.
|
|
24
|
+
* Never throws: the failing field is reported in `error` and `filled` counts the
|
|
25
|
+
* writes that actually landed, so the server can tell the caller how far the
|
|
26
|
+
* form got rather than leaving it ambiguous.
|
|
27
|
+
*/
|
|
28
|
+
async function runFillFields(ops, hooks) {
|
|
29
|
+
const res = { filled: 0 };
|
|
30
|
+
for (const op of ops) {
|
|
31
|
+
try {
|
|
32
|
+
// Fails CLOSED, exactly as the router does: no policy means nothing runs.
|
|
33
|
+
const policy = hooks.policy();
|
|
34
|
+
if (!policy)
|
|
35
|
+
throw new PolicyStop('no policy is in force; refusing to fill');
|
|
36
|
+
const verdict = (0, policy_1.evaluatePolicy)(await hooks.currentUrl(), 'fill_form', policy);
|
|
37
|
+
if (!verdict.ok)
|
|
38
|
+
throw new PolicyStop(verdict.reason);
|
|
39
|
+
await hooks.write(op);
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
res.error =
|
|
43
|
+
err instanceof PolicyStop
|
|
44
|
+
? { selector: op.selector, code: 'POLICY_DENIED', message: err.message }
|
|
45
|
+
: { selector: op.selector, ...hooks.toError(err) };
|
|
46
|
+
return res;
|
|
47
|
+
}
|
|
48
|
+
res.filled++;
|
|
49
|
+
}
|
|
50
|
+
return res;
|
|
51
|
+
}
|
|
52
|
+
/** A refusal raised by the gate itself, kept distinct from a page-op failure. */
|
|
53
|
+
class PolicyStop extends Error {
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=fill-form.js.map
|
package/dist/shared/policy.js
CHANGED
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
*
|
|
9
9
|
* The server is the WebSocket SERVER; the extension is the single privileged
|
|
10
10
|
* CLIENT that dials in. Methods on the wire mirror the MCP primitives 1:1.
|
|
11
|
-
* Helpers (extract_links / read_as_markdown
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* Helpers (extract_links / read_as_markdown) are NOT on the wire — they are
|
|
12
|
+
* composed server-side from these primitives. `download_file` is a wire method
|
|
13
|
+
* beyond the primitives, and so is `fill_form` (a batch of `type`/`click` writes
|
|
14
|
+
* in one round-trip; capability-gated, see `WIRE_CAP_FILL_FORM`).
|
|
14
15
|
*/
|
|
15
16
|
/** Bumped on any breaking change to the frames below. */
|
|
16
17
|
export declare const PROTOCOL_VERSION: 1;
|
|
@@ -39,12 +40,36 @@ export declare const CLOSE_SUPERSEDED: 4000;
|
|
|
39
40
|
* every call. Without it, the server falls back to fetching the URL itself.
|
|
40
41
|
*/
|
|
41
42
|
export declare const WIRE_CAP_TAB_URL: "tab-url";
|
|
43
|
+
/**
|
|
44
|
+
* `fill-form`: this extension handles the `fill_form` wire method — every field
|
|
45
|
+
* of a form written in ONE round-trip. Without it the server fills field by
|
|
46
|
+
* field over `type`/`click`, which every extension build understands.
|
|
47
|
+
*/
|
|
48
|
+
export declare const WIRE_CAP_FILL_FORM: "fill-form";
|
|
49
|
+
/** One field write inside a `fill_form` command: set a value, or click to toggle. */
|
|
50
|
+
export interface FillFieldOp {
|
|
51
|
+
selector: string;
|
|
52
|
+
/** A string is value-set (cleared first); a boolean toggles via a click. */
|
|
53
|
+
value: string | boolean;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* `fill_form` result. Ops run in order and stop at the first failure, so
|
|
57
|
+
* `filled` counts the fields that landed and `error` names the one that did not.
|
|
58
|
+
*/
|
|
59
|
+
export interface FillFormWireResult {
|
|
60
|
+
filled: number;
|
|
61
|
+
error?: {
|
|
62
|
+
selector: string;
|
|
63
|
+
code: ExecutorErrorCode;
|
|
64
|
+
message: string;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
42
67
|
/**
|
|
43
68
|
* Every method that may travel on the wire = the MCP primitives 1:1, plus
|
|
44
69
|
* `download_file` (privileged, executor-owned) and `ping_probe` (a short-deadline
|
|
45
70
|
* responsiveness check used to detect a dead-but-not-yet-reconnected worker).
|
|
46
71
|
*/
|
|
47
|
-
export type WireMethod = 'tabs_list' | 'tab_select' | 'tab_new' | 'tab_close' | 'navigate' | 'back' | 'forward' | 'reload' | 'click' | 'type' | 'press' | 'hover' | 'scroll' | 'screenshot' | 'get_text' | 'get_html' | 'snapshot' | 'select_option' | 'get_cookies' | 'storage' | 'eval' | 'wait_for' | 'download_file' | 'upload_file' | 'frames_list' | 'observers' | 'print_pdf' | 'ping_probe';
|
|
72
|
+
export type WireMethod = 'tabs_list' | 'tab_select' | 'tab_new' | 'tab_close' | 'navigate' | 'back' | 'forward' | 'reload' | 'click' | 'type' | 'press' | 'hover' | 'scroll' | 'screenshot' | 'get_text' | 'get_html' | 'snapshot' | 'select_option' | 'get_cookies' | 'storage' | 'eval' | 'wait_for' | 'download_file' | 'upload_file' | 'frames_list' | 'observers' | 'print_pdf' | 'fill_form' | 'ping_probe';
|
|
48
73
|
/** Runtime list of every WireMethod, for boot-time drift assertions on both ends. */
|
|
49
74
|
export declare const WIRE_METHODS: readonly WireMethod[];
|
|
50
75
|
export type ExecutorErrorCode = 'NO_TARGET' | 'TARGET_GONE' | 'DETACHED' | 'DEVTOOLS_OPEN' | 'SELECTOR_NOT_FOUND' | 'REF_EXPIRED' | 'EVAL_THREW' | 'TIMEOUT' | 'BAD_ARGS' | 'CDP_ERROR' | 'POLICY_DENIED' | 'DOWNLOAD_FAILED' | 'UPLOAD_FAILED' | 'FRAME_NOT_FOUND' | 'OBSERVERS_DISABLED' | 'UNKNOWN_METHOD';
|
package/dist/shared/protocol.js
CHANGED
|
@@ -9,12 +9,13 @@
|
|
|
9
9
|
*
|
|
10
10
|
* The server is the WebSocket SERVER; the extension is the single privileged
|
|
11
11
|
* CLIENT that dials in. Methods on the wire mirror the MCP primitives 1:1.
|
|
12
|
-
* Helpers (extract_links / read_as_markdown
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* Helpers (extract_links / read_as_markdown) are NOT on the wire — they are
|
|
13
|
+
* composed server-side from these primitives. `download_file` is a wire method
|
|
14
|
+
* beyond the primitives, and so is `fill_form` (a batch of `type`/`click` writes
|
|
15
|
+
* in one round-trip; capability-gated, see `WIRE_CAP_FILL_FORM`).
|
|
15
16
|
*/
|
|
16
17
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.WIRE_METHODS = exports.WIRE_CAP_TAB_URL = exports.CLOSE_SUPERSEDED = exports.CLOSE_UNAUTHORIZED = exports.BRIDGE_HOST = exports.DEFAULT_WS_PORT = exports.PROTOCOL_VERSION = void 0;
|
|
18
|
+
exports.WIRE_METHODS = exports.WIRE_CAP_FILL_FORM = exports.WIRE_CAP_TAB_URL = exports.CLOSE_SUPERSEDED = exports.CLOSE_UNAUTHORIZED = exports.BRIDGE_HOST = exports.DEFAULT_WS_PORT = exports.PROTOCOL_VERSION = void 0;
|
|
18
19
|
/** Bumped on any breaking change to the frames below. */
|
|
19
20
|
exports.PROTOCOL_VERSION = 1;
|
|
20
21
|
/**
|
|
@@ -41,6 +42,12 @@ exports.CLOSE_SUPERSEDED = 4000;
|
|
|
41
42
|
* every call. Without it, the server falls back to fetching the URL itself.
|
|
42
43
|
*/
|
|
43
44
|
exports.WIRE_CAP_TAB_URL = 'tab-url';
|
|
45
|
+
/**
|
|
46
|
+
* `fill-form`: this extension handles the `fill_form` wire method — every field
|
|
47
|
+
* of a form written in ONE round-trip. Without it the server fills field by
|
|
48
|
+
* field over `type`/`click`, which every extension build understands.
|
|
49
|
+
*/
|
|
50
|
+
exports.WIRE_CAP_FILL_FORM = 'fill-form';
|
|
44
51
|
/** Runtime list of every WireMethod, for boot-time drift assertions on both ends. */
|
|
45
52
|
exports.WIRE_METHODS = [
|
|
46
53
|
'tabs_list',
|
|
@@ -70,6 +77,7 @@ exports.WIRE_METHODS = [
|
|
|
70
77
|
'frames_list',
|
|
71
78
|
'observers',
|
|
72
79
|
'print_pdf',
|
|
80
|
+
'fill_form',
|
|
73
81
|
'ping_probe',
|
|
74
82
|
];
|
|
75
83
|
//# sourceMappingURL=protocol.js.map
|
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import type { WebSocket } from 'ws';
|
|
14
14
|
import { type WireEvent, type WireMethod } from '../../shared/protocol';
|
|
15
|
+
import { type ExecutorErrorCodeLocal } from '../executor/types';
|
|
16
|
+
/** Map a wire error code onto a local ExecutorError code (the wire enum is a
|
|
17
|
+
* near-superset; unknown codes degrade to CDP_ERROR while keeping the message). */
|
|
18
|
+
export declare function mapWireErrorCode(code: string): ExecutorErrorCodeLocal;
|
|
15
19
|
export interface ConnectionDeps {
|
|
16
20
|
ws: WebSocket;
|
|
17
21
|
extId: string;
|
|
@@ -34,6 +38,8 @@ export declare class ExtensionConnection {
|
|
|
34
38
|
private missedPongs;
|
|
35
39
|
/** Whether this extension reports `tabUrl` and gates fail-closed. */
|
|
36
40
|
private readonly reportsTabUrl;
|
|
41
|
+
/** Every capability the extension advertised in `hello`. */
|
|
42
|
+
private readonly caps;
|
|
37
43
|
/** Last URL the ACTIVE tab reported, with the wall-clock it arrived. */
|
|
38
44
|
private activeUrl;
|
|
39
45
|
/** Last URL each explicitly-targeted tab reported, keyed by wire tab id. A
|
|
@@ -69,6 +75,8 @@ export declare class ExtensionConnection {
|
|
|
69
75
|
* to forget what we knew, never to keep believing it.
|
|
70
76
|
*/
|
|
71
77
|
private rememberActiveUrl;
|
|
78
|
+
/** Whether the extension advertised `cap` in its `hello`. */
|
|
79
|
+
hasCap(cap: string): boolean;
|
|
72
80
|
/**
|
|
73
81
|
* The active tab's last reported URL if it is younger than `maxAgeMs`, else
|
|
74
82
|
* null — the caller then resolves it the slow way. Never returns a guess.
|
|
@@ -13,10 +13,11 @@
|
|
|
13
13
|
*/
|
|
14
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
15
|
exports.ExtensionConnection = void 0;
|
|
16
|
+
exports.mapWireErrorCode = mapWireErrorCode;
|
|
16
17
|
const protocol_1 = require("../../shared/protocol");
|
|
17
18
|
const types_1 = require("../executor/types");
|
|
18
19
|
const MAX_BUFFERED_BYTES = 8 * 1024 * 1024;
|
|
19
|
-
const LONG_METHODS = new Set(['screenshot', 'wait_for', 'navigate', 'download_file']);
|
|
20
|
+
const LONG_METHODS = new Set(['screenshot', 'wait_for', 'navigate', 'download_file', 'fill_form']);
|
|
20
21
|
function defaultTimeoutFor(method) {
|
|
21
22
|
return LONG_METHODS.has(method) ? 60_000 : 30_000;
|
|
22
23
|
}
|
|
@@ -51,6 +52,8 @@ class ExtensionConnection {
|
|
|
51
52
|
missedPongs = 0;
|
|
52
53
|
/** Whether this extension reports `tabUrl` and gates fail-closed. */
|
|
53
54
|
reportsTabUrl;
|
|
55
|
+
/** Every capability the extension advertised in `hello`. */
|
|
56
|
+
caps;
|
|
54
57
|
/** Last URL the ACTIVE tab reported, with the wall-clock it arrived. */
|
|
55
58
|
activeUrl = null;
|
|
56
59
|
/** Last URL each explicitly-targeted tab reported, keyed by wire tab id. A
|
|
@@ -64,7 +67,8 @@ class ExtensionConnection {
|
|
|
64
67
|
this.ws = deps.ws;
|
|
65
68
|
this.extId = deps.extId;
|
|
66
69
|
this.sessionId = deps.sessionId;
|
|
67
|
-
this.
|
|
70
|
+
this.caps = new Set(deps.caps ?? []);
|
|
71
|
+
this.reportsTabUrl = this.caps.has(protocol_1.WIRE_CAP_TAB_URL);
|
|
68
72
|
this.onEvent = deps.onEvent;
|
|
69
73
|
this.onClose = deps.onClose;
|
|
70
74
|
this.onLog = deps.onLog;
|
|
@@ -237,6 +241,10 @@ class ExtensionConnection {
|
|
|
237
241
|
return; // already invalidated; re-caching would race
|
|
238
242
|
this.activeUrl = frame.tabUrl ? { url: frame.tabUrl, at: Date.now() } : null;
|
|
239
243
|
}
|
|
244
|
+
/** Whether the extension advertised `cap` in its `hello`. */
|
|
245
|
+
hasCap(cap) {
|
|
246
|
+
return this.caps.has(cap);
|
|
247
|
+
}
|
|
240
248
|
/**
|
|
241
249
|
* The active tab's last reported URL if it is younger than `maxAgeMs`, else
|
|
242
250
|
* null — the caller then resolves it the slow way. Never returns a guess.
|
|
@@ -122,6 +122,9 @@ export declare class BridgeServer {
|
|
|
122
122
|
timeoutMs?: number;
|
|
123
123
|
profile?: string;
|
|
124
124
|
}): Promise<unknown>;
|
|
125
|
+
/** Whether the browser paired as `profile` advertised capability `cap`. A peer
|
|
126
|
+
* cannot see the hub's handshake, so it answers false (the conservative path). */
|
|
127
|
+
hasCap(profile: string | undefined, cap: string): boolean;
|
|
125
128
|
/**
|
|
126
129
|
* The active tab's URL for `profile` as last reported by the extension, if it
|
|
127
130
|
* is younger than `maxAgeMs`. Null means "ask properly" — an extension too old
|
|
@@ -296,6 +296,14 @@ class BridgeServer {
|
|
|
296
296
|
}
|
|
297
297
|
return conn.sendCommand(method, params, opts);
|
|
298
298
|
}
|
|
299
|
+
/** Whether the browser paired as `profile` advertised capability `cap`. A peer
|
|
300
|
+
* cannot see the hub's handshake, so it answers false (the conservative path). */
|
|
301
|
+
hasCap(profile, cap) {
|
|
302
|
+
if (this.hub)
|
|
303
|
+
return false;
|
|
304
|
+
const conn = this.conns.get(routeKey(profile));
|
|
305
|
+
return !!conn?.isOpen() && conn.hasCap(cap);
|
|
306
|
+
}
|
|
299
307
|
/**
|
|
300
308
|
* The active tab's URL for `profile` as last reported by the extension, if it
|
|
301
309
|
* is younger than `maxAgeMs`. Null means "ask properly" — an extension too old
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* method-specific arguments travel in `params`. Results are trusted shapes
|
|
8
8
|
* produced by the extension router (validated there).
|
|
9
9
|
*/
|
|
10
|
-
import { type ActionOk, type BackendKind, type CookieItem, type DownloadResult, type EvalResult, type Executor, type ExecutorStatus, type FrameInfo, type FrameOpts, type ObserverArgs, type ObserverReadResult, type PdfResult, type KeyModifier, type MouseButton, type NavResult, type ScreenshotEncoding, type ScreenshotResult, type SnapshotLocator, type SnapshotResult, type StorageOp, type StorageResult, type TabId, type TabInfo, type Target, type WaitResult, type WaitUntil } from './types';
|
|
10
|
+
import { type ActionOk, type BackendKind, type CookieItem, type DownloadResult, type EvalResult, type Executor, type ExecutorStatus, type FillFieldOp, type FrameInfo, type FrameOpts, type ObserverArgs, type ObserverReadResult, type PdfResult, type KeyModifier, type MouseButton, type NavResult, type ScreenshotEncoding, type ScreenshotResult, type SnapshotLocator, type SnapshotResult, type StorageOp, type StorageResult, type TabId, type TabInfo, type Target, type WaitResult, type WaitUntil } from './types';
|
|
11
11
|
import type { BridgeServer } from '../bridge/server';
|
|
12
12
|
export declare class ExtensionExecutor implements Executor {
|
|
13
13
|
private readonly bridge;
|
|
@@ -69,6 +69,11 @@ export declare class ExtensionExecutor implements Executor {
|
|
|
69
69
|
fill(t: Target, value: string, opts?: {
|
|
70
70
|
tabId?: TabId;
|
|
71
71
|
} & FrameOpts): Promise<ActionOk>;
|
|
72
|
+
fillFields(fields: FillFieldOp[], opts?: {
|
|
73
|
+
tabId?: TabId;
|
|
74
|
+
} & FrameOpts): Promise<{
|
|
75
|
+
filled: number;
|
|
76
|
+
} | null>;
|
|
72
77
|
press(key: string, opts?: {
|
|
73
78
|
tabId?: TabId;
|
|
74
79
|
modifiers?: KeyModifier[];
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
12
|
exports.ExtensionExecutor = void 0;
|
|
13
13
|
const types_1 = require("./types");
|
|
14
|
+
const protocol_1 = require("../../shared/protocol");
|
|
15
|
+
const connection_1 = require("../bridge/connection");
|
|
14
16
|
const workspace_1 = require("../bridge/workspace");
|
|
15
17
|
/**
|
|
16
18
|
* How long a reported active-tab URL stays usable for the policy gate.
|
|
@@ -143,6 +145,19 @@ class ExtensionExecutor {
|
|
|
143
145
|
// No dedicated wire method: a cleared insertText is the fill primitive.
|
|
144
146
|
return (await this.send('type', { ...targetParams(t), ...frameParams(opts), text: value, clear: true, keyEvents: false }, { tabId: opts?.tabId }));
|
|
145
147
|
}
|
|
148
|
+
async fillFields(fields, opts) {
|
|
149
|
+
// An extension that predates the op never advertised it: let the caller go field by field.
|
|
150
|
+
if (!this.bridge.hasCap(this.activeProfile(), protocol_1.WIRE_CAP_FILL_FORM))
|
|
151
|
+
return null;
|
|
152
|
+
const res = (await this.send('fill_form', { ops: fields, ...frameParams(opts) }, { tabId: opts?.tabId }));
|
|
153
|
+
if (res.error) {
|
|
154
|
+
// Say how far the batch got: the fields before this one DID land, and a
|
|
155
|
+
// blind retry of the whole form would write them a second time.
|
|
156
|
+
const where = `field ${res.filled + 1} of ${fields.length} (${res.error.selector}) failed after ${res.filled} filled`;
|
|
157
|
+
throw new types_1.ExecutorError((0, connection_1.mapWireErrorCode)(res.error.code), `${where}: ${res.error.message}`);
|
|
158
|
+
}
|
|
159
|
+
return { filled: res.filled };
|
|
160
|
+
}
|
|
146
161
|
async press(key, opts) {
|
|
147
162
|
return (await this.send('press', { key, modifiers: opts?.modifiers }, { tabId: opts?.tabId }));
|
|
148
163
|
}
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* `mcp/helpers.ts` from these primitives; only `download` is privileged.
|
|
11
11
|
*/
|
|
12
12
|
import type { ObserverReadResult, DialogPolicy } from '../../shared/observers';
|
|
13
|
-
|
|
13
|
+
import type { FillFieldOp } from '../../shared/protocol';
|
|
14
|
+
export type { ObserverReadResult, DialogPolicy, FillFieldOp };
|
|
14
15
|
export type BackendKind = 'extension' | 'cdp';
|
|
15
16
|
export type WaitUntil = 'load' | 'domcontentloaded' | 'networkidle';
|
|
16
17
|
export type KeyModifier = 'Alt' | 'Control' | 'Meta' | 'Shift';
|
|
@@ -334,6 +335,17 @@ export interface Executor {
|
|
|
334
335
|
gone?: boolean;
|
|
335
336
|
timeoutMs?: number;
|
|
336
337
|
} & FrameOpts): Promise<WaitResult>;
|
|
338
|
+
/**
|
|
339
|
+
* Write several fields in ONE round-trip (string = value-set like `fill`,
|
|
340
|
+
* boolean = toggle via click), in order, throwing the first field's failure.
|
|
341
|
+
* Resolves null when the live backend cannot batch (e.g. an extension build
|
|
342
|
+
* older than the op) — the caller then falls back to `fill`/`click` per field.
|
|
343
|
+
*/
|
|
344
|
+
fillFields?(fields: FillFieldOp[], opts?: {
|
|
345
|
+
tabId?: TabId;
|
|
346
|
+
} & FrameOpts): Promise<{
|
|
347
|
+
filled: number;
|
|
348
|
+
} | null>;
|
|
337
349
|
/** Every frame of a tab that the extension can inject into, with its URL. */
|
|
338
350
|
framesList?(opts?: {
|
|
339
351
|
tabId?: TabId;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* src/mcp/helpers.ts — the high-level tools composed SERVER-SIDE from executor
|
|
3
3
|
* primitives. They never touch the wire directly: `extract_links` and
|
|
4
|
-
* `read_as_markdown` read via primitives; `fill_form`
|
|
4
|
+
* `read_as_markdown` read via primitives; `fill_form` batches its field writes
|
|
5
|
+
* when the backend can (else sequences fill+click).
|
|
5
6
|
* (Only `download_file` is privileged and lives on the executor.)
|
|
6
7
|
*/
|
|
7
8
|
import type { Executor, FrameOpts } from '../executor/types';
|
|
@@ -32,7 +33,12 @@ export declare function readAsMarkdown(ex: Executor, args: {
|
|
|
32
33
|
selector?: string;
|
|
33
34
|
tabId?: string;
|
|
34
35
|
} & FrameOpts): Promise<string>;
|
|
35
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Fill a set of fields (keyed by selector) and optionally submit. All writes go
|
|
38
|
+
* out as ONE executor call when the backend can batch them; otherwise (older
|
|
39
|
+
* extension, CDP) they fall back to one fill/click per field. Either way the
|
|
40
|
+
* first failing field throws, and submit is a separate click after the fills.
|
|
41
|
+
*/
|
|
36
42
|
export declare function fillForm(ex: Executor, args: {
|
|
37
43
|
fields: Record<string, string | boolean>;
|
|
38
44
|
submitSelector?: string;
|
package/dist/src/mcp/helpers.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* src/mcp/helpers.ts — the high-level tools composed SERVER-SIDE from executor
|
|
4
4
|
* primitives. They never touch the wire directly: `extract_links` and
|
|
5
|
-
* `read_as_markdown` read via primitives; `fill_form`
|
|
5
|
+
* `read_as_markdown` read via primitives; `fill_form` batches its field writes
|
|
6
|
+
* when the backend can (else sequences fill+click).
|
|
6
7
|
* (Only `download_file` is privileged and lives on the executor.)
|
|
7
8
|
*/
|
|
8
9
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
@@ -80,20 +81,29 @@ async function readAsMarkdown(ex, args) {
|
|
|
80
81
|
});
|
|
81
82
|
return (0, markdown_extract_1.htmlToMarkdown)(html);
|
|
82
83
|
}
|
|
83
|
-
/**
|
|
84
|
+
/**
|
|
85
|
+
* Fill a set of fields (keyed by selector) and optionally submit. All writes go
|
|
86
|
+
* out as ONE executor call when the backend can batch them; otherwise (older
|
|
87
|
+
* extension, CDP) they fall back to one fill/click per field. Either way the
|
|
88
|
+
* first failing field throws, and submit is a separate click after the fills.
|
|
89
|
+
*/
|
|
84
90
|
async function fillForm(ex, args) {
|
|
85
91
|
const opts = { tabId: args.tabId, frameId: args.frameId, allFrames: args.allFrames };
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
const ops = Object.entries(args.fields).map(([selector, value]) => ({ selector, value }));
|
|
93
|
+
const batched = ops.length > 0 && ex.fillFields ? await ex.fillFields(ops, opts) : null;
|
|
94
|
+
let filled = batched?.filled ?? 0;
|
|
95
|
+
if (!batched) {
|
|
96
|
+
for (const { selector, value } of ops) {
|
|
97
|
+
const target = { selector };
|
|
98
|
+
if (typeof value === 'boolean') {
|
|
99
|
+
// Checkbox/radio: a click toggles it.
|
|
100
|
+
await ex.click(target, opts);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
await ex.fill(target, value, opts);
|
|
104
|
+
}
|
|
105
|
+
filled++;
|
|
95
106
|
}
|
|
96
|
-
filled++;
|
|
97
107
|
}
|
|
98
108
|
let submitted = false;
|
|
99
109
|
if (args.submitSelector) {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// shared/protocol.ts
|
|
4
4
|
var PROTOCOL_VERSION = 1;
|
|
5
5
|
var WIRE_CAP_TAB_URL = "tab-url";
|
|
6
|
+
var WIRE_CAP_FILL_FORM = "fill-form";
|
|
6
7
|
var WIRE_METHODS = [
|
|
7
8
|
"tabs_list",
|
|
8
9
|
"tab_select",
|
|
@@ -31,6 +32,7 @@
|
|
|
31
32
|
"frames_list",
|
|
32
33
|
"observers",
|
|
33
34
|
"print_pdf",
|
|
35
|
+
"fill_form",
|
|
34
36
|
"ping_probe"
|
|
35
37
|
];
|
|
36
38
|
|
|
@@ -72,7 +74,7 @@
|
|
|
72
74
|
// This build gates fail-closed and reports tab URLs on results, so the
|
|
73
75
|
// server may skip its pre-flight tabs_list. An older build omits this and
|
|
74
76
|
// the server keeps fetching the URL itself.
|
|
75
|
-
caps: [WIRE_CAP_TAB_URL]
|
|
77
|
+
caps: [WIRE_CAP_TAB_URL, WIRE_CAP_FILL_FORM]
|
|
76
78
|
};
|
|
77
79
|
ws2.send(JSON.stringify(hello));
|
|
78
80
|
};
|
|
@@ -146,7 +148,8 @@
|
|
|
146
148
|
"type",
|
|
147
149
|
"press",
|
|
148
150
|
"hover",
|
|
149
|
-
"scroll"
|
|
151
|
+
"scroll",
|
|
152
|
+
"fill_form"
|
|
150
153
|
]);
|
|
151
154
|
var NAVIGATION = /* @__PURE__ */ new Set([
|
|
152
155
|
"navigate",
|
|
@@ -236,6 +239,27 @@
|
|
|
236
239
|
return `Blocked: "${method}" can't run on ${host} because it isn't on this browser tool's allowed-sites list. This is a safety limit (the tool drives your real, logged-in browser, so it only touches sites you've approved) \u2014 not an error. ${allowedLine} To allow ${host}, add it to the chrome-mcp settings as: --allow-domain "${host}" (or "*.${host}" to include subdomains), then restart/reconnect. To allow every site (less safe), use --unsafe-all-domains.`;
|
|
237
240
|
}
|
|
238
241
|
|
|
242
|
+
// shared/fill-form.ts
|
|
243
|
+
async function runFillFields(ops, hooks) {
|
|
244
|
+
const res = { filled: 0 };
|
|
245
|
+
for (const op of ops) {
|
|
246
|
+
try {
|
|
247
|
+
const policy = hooks.policy();
|
|
248
|
+
if (!policy) throw new PolicyStop("no policy is in force; refusing to fill");
|
|
249
|
+
const verdict = evaluatePolicy(await hooks.currentUrl(), "fill_form", policy);
|
|
250
|
+
if (!verdict.ok) throw new PolicyStop(verdict.reason);
|
|
251
|
+
await hooks.write(op);
|
|
252
|
+
} catch (err) {
|
|
253
|
+
res.error = err instanceof PolicyStop ? { selector: op.selector, code: "POLICY_DENIED", message: err.message } : { selector: op.selector, ...hooks.toError(err) };
|
|
254
|
+
return res;
|
|
255
|
+
}
|
|
256
|
+
res.filled++;
|
|
257
|
+
}
|
|
258
|
+
return res;
|
|
259
|
+
}
|
|
260
|
+
var PolicyStop = class extends Error {
|
|
261
|
+
};
|
|
262
|
+
|
|
239
263
|
// shared/download.ts
|
|
240
264
|
var MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024;
|
|
241
265
|
var DANGEROUS_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
@@ -1225,7 +1249,8 @@
|
|
|
1225
1249
|
"ping_probe",
|
|
1226
1250
|
"frames_list",
|
|
1227
1251
|
"observers",
|
|
1228
|
-
"print_pdf"
|
|
1252
|
+
"print_pdf",
|
|
1253
|
+
"fill_form"
|
|
1229
1254
|
]);
|
|
1230
1255
|
var ChromeExecutor = class {
|
|
1231
1256
|
/**
|
|
@@ -1495,6 +1520,34 @@
|
|
|
1495
1520
|
if (!out.found) throw new CmdError("SELECTOR_NOT_FOUND", `no element for selector: ${sel}`);
|
|
1496
1521
|
return { ok: true, frameId: out.frameId };
|
|
1497
1522
|
}
|
|
1523
|
+
case "fill_form": {
|
|
1524
|
+
const id = await targetTab2(cmd);
|
|
1525
|
+
const raw = Array.isArray(cmd.params.ops) ? cmd.params.ops : [];
|
|
1526
|
+
const ops = raw.map((f) => ({
|
|
1527
|
+
selector: String(f.selector ?? ""),
|
|
1528
|
+
value: typeof f.value === "boolean" ? f.value : String(f.value ?? "")
|
|
1529
|
+
}));
|
|
1530
|
+
return runFillFields(ops, {
|
|
1531
|
+
currentUrl: () => observedTabUrl(cmd, id),
|
|
1532
|
+
policy: () => this.getPolicy(),
|
|
1533
|
+
// Frame ids are re-probed per field too: an iframe that navigates
|
|
1534
|
+
// mid-batch must not keep a grant the policy would no longer give it.
|
|
1535
|
+
write: async (op) => {
|
|
1536
|
+
const out = await execOp(
|
|
1537
|
+
id,
|
|
1538
|
+
withWait(
|
|
1539
|
+
typeof op.value === "boolean" ? { op: "click", selector: op.selector } : { op: "type", selector: op.selector, text: op.value, clear: true }
|
|
1540
|
+
),
|
|
1541
|
+
await this.frames(cmd, id)
|
|
1542
|
+
);
|
|
1543
|
+
if (!out.found) throw new CmdError("SELECTOR_NOT_FOUND", `no element for selector: ${op.selector}`);
|
|
1544
|
+
},
|
|
1545
|
+
toError: (err) => ({
|
|
1546
|
+
code: err instanceof CmdError ? err.code : "CDP_ERROR",
|
|
1547
|
+
message: err instanceof Error ? err.message : String(err)
|
|
1548
|
+
})
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1498
1551
|
case "press": {
|
|
1499
1552
|
const id = await targetTab2(cmd);
|
|
1500
1553
|
const key = String(cmd.params.key ?? "");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "MCP Extension for Chrome",
|
|
4
|
-
"version": "0.9.
|
|
4
|
+
"version": "0.9.8",
|
|
5
5
|
"description": "Lets a local chrome-mcp server drive this browser. Pair it with the server's handshake token.",
|
|
6
6
|
"homepage_url": "https://chrome-mcp-omega.vercel.app",
|
|
7
7
|
"minimum_chrome_version": "116",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mehmoodqureshi/chrome-mcp",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.8",
|
|
4
4
|
"description": "Drive your real Chrome browser over MCP — real logins, real cookies. A stdio MCP server (CLI) plus an MV3 extension, driving Chrome via chrome.scripting/chrome.tabs. Multi-tab batch automation, accessibility snapshots, deny-all security by default.",
|
|
5
5
|
"author": "Mehmood Ur Rehman Qureshi",
|
|
6
6
|
"license": "MIT",
|