@mehmoodqureshi/chrome-mcp 0.6.5 → 0.6.7
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/src/cli.js +13 -0
- package/dist/src/executor/stub-executor.d.ts +37 -0
- package/dist/src/executor/stub-executor.js +48 -4
- package/dist/src/mcp/batch.js +61 -9
- package/dist/src/mcp/limits.d.ts +43 -0
- package/dist/src/mcp/limits.js +87 -0
- package/dist/src/mcp/log.d.ts +17 -0
- package/dist/src/mcp/log.js +43 -0
- package/dist/src/mcp/server.d.ts +1 -2
- package/dist/src/mcp/server.js +16 -12
- package/dist/src/mcp/tools.js +123 -50
- package/package.json +1 -1
package/dist/src/cli.js
CHANGED
|
@@ -142,6 +142,10 @@ async function main() {
|
|
|
142
142
|
if (runTasksCommand(process.argv.slice(2)))
|
|
143
143
|
return;
|
|
144
144
|
const cfg = (0, config_1.parseArgs)(process.argv.slice(2));
|
|
145
|
+
// Apply verbosity before anything else logs, so `--log-level silent` really is
|
|
146
|
+
// silent from the first line rather than from wherever the first log happened
|
|
147
|
+
// to sit after startup.
|
|
148
|
+
(0, server_2.setLogLevel)(cfg.logLevel);
|
|
145
149
|
if (cfg.showHelp) {
|
|
146
150
|
process.stdout.write(config_1.HELP_TEXT);
|
|
147
151
|
return;
|
|
@@ -164,6 +168,15 @@ async function main() {
|
|
|
164
168
|
onDisplacement: (d) => (0, server_2.logErr)(`SECURITY: extension connection displaced (different id: ${d.differentId})`),
|
|
165
169
|
});
|
|
166
170
|
const port = await bridge.start();
|
|
171
|
+
// Never includes the pairing token — only the resolved, non-secret config.
|
|
172
|
+
(0, server_2.logDebug)(`resolved config: ${JSON.stringify({
|
|
173
|
+
wsPort: port,
|
|
174
|
+
dataDir: cfg.dataDir,
|
|
175
|
+
profile: cfg.profile,
|
|
176
|
+
task: cfg.task,
|
|
177
|
+
prefer: cfg.prefer,
|
|
178
|
+
policy: cfg.policy,
|
|
179
|
+
})}`);
|
|
167
180
|
const handshakePath = (0, auth_1.writeHandshake)(dataDir, { port, token });
|
|
168
181
|
(0, server_2.logErr)(`pairing handshake written to ${handshakePath} (mode 0600; token not logged)`);
|
|
169
182
|
if (process.env.CHROME_MCP_TOKEN) {
|
|
@@ -23,6 +23,31 @@ export interface StubOptions {
|
|
|
23
23
|
/** A URL the backend claims to already know, as the extension reports on every
|
|
24
24
|
* result frame. Set it to assert the gate uses it INSTEAD of calling tabsList. */
|
|
25
25
|
cachedUrl?: string;
|
|
26
|
+
/** Background (non-active) tabs, so a test can target one by an explicit tabId
|
|
27
|
+
* and check the gate authorizes against THAT tab rather than the active one. */
|
|
28
|
+
backgroundTabs?: Array<{
|
|
29
|
+
tabId: TabId;
|
|
30
|
+
url: string;
|
|
31
|
+
}>;
|
|
32
|
+
/** When true, tabs exist but none is flagged active — the case the gate used to
|
|
33
|
+
* paper over by silently gating against `tabs[0]`. */
|
|
34
|
+
noActiveTab?: boolean;
|
|
35
|
+
/** Text returned by `getText`. Set it large to exercise the output cap. */
|
|
36
|
+
textPayload?: string;
|
|
37
|
+
/** HTML returned by `getHtml`. Set it large to exercise the output cap. */
|
|
38
|
+
htmlPayload?: string;
|
|
39
|
+
/**
|
|
40
|
+
* How many of the first content reads reject with `EXTENSION_DISCONNECTED`
|
|
41
|
+
* before one succeeds — models MV3 recycling the service worker mid-command,
|
|
42
|
+
* the fault the dispatch layer retries once.
|
|
43
|
+
*/
|
|
44
|
+
disconnectReads?: number;
|
|
45
|
+
/**
|
|
46
|
+
* Same, but for the mutating `type` path. Mutations are deliberately NOT
|
|
47
|
+
* retried — repeating a write could submit a form twice — so a test can assert
|
|
48
|
+
* exactly one attempt was made.
|
|
49
|
+
*/
|
|
50
|
+
disconnectWrites?: number;
|
|
26
51
|
}
|
|
27
52
|
export declare class StubExecutor implements Executor {
|
|
28
53
|
readonly backend: BackendKind;
|
|
@@ -30,13 +55,25 @@ export declare class StubExecutor implements Executor {
|
|
|
30
55
|
private readonly evalThrows;
|
|
31
56
|
private readonly tabsListThrows;
|
|
32
57
|
private readonly noTabs;
|
|
58
|
+
private readonly textPayload;
|
|
59
|
+
private readonly htmlPayload;
|
|
60
|
+
private remainingDisconnects;
|
|
61
|
+
private remainingWriteDisconnects;
|
|
33
62
|
private readonly blankTabUrl;
|
|
34
63
|
private readonly cached;
|
|
64
|
+
private readonly backgroundTabs;
|
|
65
|
+
private readonly noActiveTab;
|
|
35
66
|
/** How many times the gate actually asked for the tab list — the round-trip
|
|
36
67
|
* counter the caching path exists to keep at zero. */
|
|
37
68
|
tabsListCalls: number;
|
|
38
69
|
private ready;
|
|
39
70
|
constructor(opts?: StubOptions);
|
|
71
|
+
/** Fail this read if a scripted disconnect is still pending, then consume it. */
|
|
72
|
+
private maybeDisconnect;
|
|
73
|
+
/** How many scripted disconnects are left (lets a test assert one was consumed). */
|
|
74
|
+
get pendingDisconnects(): number;
|
|
75
|
+
/** Same for the write path — a mutating call must consume exactly one. */
|
|
76
|
+
get pendingWriteDisconnects(): number;
|
|
40
77
|
private tab;
|
|
41
78
|
cachedActiveUrl(): string | null;
|
|
42
79
|
status(): ExecutorStatus;
|
|
@@ -20,8 +20,14 @@ class StubExecutor {
|
|
|
20
20
|
evalThrows;
|
|
21
21
|
tabsListThrows;
|
|
22
22
|
noTabs;
|
|
23
|
+
textPayload;
|
|
24
|
+
htmlPayload;
|
|
25
|
+
remainingDisconnects;
|
|
26
|
+
remainingWriteDisconnects;
|
|
23
27
|
blankTabUrl;
|
|
24
28
|
cached;
|
|
29
|
+
backgroundTabs;
|
|
30
|
+
noActiveTab;
|
|
25
31
|
/** How many times the gate actually asked for the tab list — the round-trip
|
|
26
32
|
* counter the caching path exists to keep at zero. */
|
|
27
33
|
tabsListCalls = 0;
|
|
@@ -33,13 +39,34 @@ class StubExecutor {
|
|
|
33
39
|
this.noTabs = opts.noTabs ?? false;
|
|
34
40
|
this.blankTabUrl = opts.blankTabUrl ?? false;
|
|
35
41
|
this.cached = opts.cachedUrl ?? null;
|
|
42
|
+
this.backgroundTabs = opts.backgroundTabs ?? [];
|
|
43
|
+
this.noActiveTab = opts.noActiveTab ?? false;
|
|
44
|
+
this.textPayload = opts.textPayload ?? 'stub text';
|
|
45
|
+
this.htmlPayload = opts.htmlPayload ?? '<html><body><a href="https://example.com">Example</a></body></html>';
|
|
46
|
+
this.remainingDisconnects = opts.disconnectReads ?? 0;
|
|
47
|
+
this.remainingWriteDisconnects = opts.disconnectWrites ?? 0;
|
|
48
|
+
}
|
|
49
|
+
/** Fail this read if a scripted disconnect is still pending, then consume it. */
|
|
50
|
+
maybeDisconnect() {
|
|
51
|
+
if (this.remainingDisconnects <= 0)
|
|
52
|
+
return;
|
|
53
|
+
this.remainingDisconnects--;
|
|
54
|
+
throw new types_1.ExecutorError('EXTENSION_DISCONNECTED', 'stub: service worker recycled mid-command');
|
|
55
|
+
}
|
|
56
|
+
/** How many scripted disconnects are left (lets a test assert one was consumed). */
|
|
57
|
+
get pendingDisconnects() {
|
|
58
|
+
return this.remainingDisconnects;
|
|
59
|
+
}
|
|
60
|
+
/** Same for the write path — a mutating call must consume exactly one. */
|
|
61
|
+
get pendingWriteDisconnects() {
|
|
62
|
+
return this.remainingWriteDisconnects;
|
|
36
63
|
}
|
|
37
64
|
tab() {
|
|
38
65
|
return {
|
|
39
66
|
tabId: 'extension:stub:1',
|
|
40
67
|
url: this.blankTabUrl ? '' : this.url,
|
|
41
68
|
title: 'Stub Page',
|
|
42
|
-
active:
|
|
69
|
+
active: !this.noActiveTab,
|
|
43
70
|
index: 0,
|
|
44
71
|
};
|
|
45
72
|
}
|
|
@@ -68,7 +95,18 @@ class StubExecutor {
|
|
|
68
95
|
this.tabsListCalls++;
|
|
69
96
|
if (this.tabsListThrows)
|
|
70
97
|
throw new types_1.ExecutorError('EXTENSION_DISCONNECTED', 'stub bridge is down');
|
|
71
|
-
|
|
98
|
+
if (this.noTabs)
|
|
99
|
+
return [];
|
|
100
|
+
return [
|
|
101
|
+
this.tab(),
|
|
102
|
+
...this.backgroundTabs.map((t, i) => ({
|
|
103
|
+
tabId: t.tabId,
|
|
104
|
+
url: t.url,
|
|
105
|
+
title: 'Stub Background Page',
|
|
106
|
+
active: false,
|
|
107
|
+
index: i + 1,
|
|
108
|
+
})),
|
|
109
|
+
];
|
|
72
110
|
}
|
|
73
111
|
async tabSelect(tabId) {
|
|
74
112
|
return { ...this.tab(), tabId };
|
|
@@ -98,6 +136,10 @@ class StubExecutor {
|
|
|
98
136
|
return ok;
|
|
99
137
|
}
|
|
100
138
|
async type() {
|
|
139
|
+
if (this.remainingWriteDisconnects > 0) {
|
|
140
|
+
this.remainingWriteDisconnects--;
|
|
141
|
+
throw new types_1.ExecutorError('EXTENSION_DISCONNECTED', 'stub: service worker recycled mid-command');
|
|
142
|
+
}
|
|
101
143
|
return ok;
|
|
102
144
|
}
|
|
103
145
|
async fill() {
|
|
@@ -116,10 +158,12 @@ class StubExecutor {
|
|
|
116
158
|
return ok;
|
|
117
159
|
}
|
|
118
160
|
async getText(_t) {
|
|
119
|
-
|
|
161
|
+
this.maybeDisconnect();
|
|
162
|
+
return { text: this.textPayload, ref: 'el_stub_1' };
|
|
120
163
|
}
|
|
121
164
|
async getHtml() {
|
|
122
|
-
|
|
165
|
+
this.maybeDisconnect();
|
|
166
|
+
return { html: this.htmlPayload };
|
|
123
167
|
}
|
|
124
168
|
async snapshot() {
|
|
125
169
|
return {
|
package/dist/src/mcp/batch.js
CHANGED
|
@@ -23,6 +23,17 @@ const validators_1 = require("./validators");
|
|
|
23
23
|
const MAX_OPS = 50;
|
|
24
24
|
const DEFAULT_CONCURRENCY = 6;
|
|
25
25
|
const MAX_CONCURRENCY = 16;
|
|
26
|
+
/**
|
|
27
|
+
* Default ceiling on the TOTAL payload a batch returns.
|
|
28
|
+
*
|
|
29
|
+
* Each op is individually bounded, but a batch multiplies: 50 screenshots or 50
|
|
30
|
+
* `get_html` reads compose into one unbounded result — which is exactly the case
|
|
31
|
+
* `batch` is most useful for. Past the budget, an op's blocks are replaced by a
|
|
32
|
+
* one-line summary so the caller still learns it ran and what it produced.
|
|
33
|
+
*/
|
|
34
|
+
const DEFAULT_MAX_RESULT_BYTES = 1024 * 1024;
|
|
35
|
+
const MIN_RESULT_BYTES = 4 * 1024;
|
|
36
|
+
const MAX_RESULT_BYTES = 32 * 1024 * 1024;
|
|
26
37
|
/** Validate the `ops` envelope. Structural problems throw (the whole batch is
|
|
27
38
|
* malformed); per-op semantic problems are handled later as per-op errors. */
|
|
28
39
|
function parseOps(raw) {
|
|
@@ -70,6 +81,7 @@ async function runBatch(rawArgs, deps) {
|
|
|
70
81
|
}
|
|
71
82
|
const stopOnError = (0, validators_1.optionalBoolean)(a, 'stopOnError') ?? false;
|
|
72
83
|
const concurrency = (0, validators_1.optionalNumber)(a, 'maxConcurrency', { min: 1, max: MAX_CONCURRENCY }) ?? DEFAULT_CONCURRENCY;
|
|
84
|
+
const maxResultBytes = (0, validators_1.optionalNumber)(a, 'maxResultBytes', { min: MIN_RESULT_BYTES, max: MAX_RESULT_BYTES }) ?? DEFAULT_MAX_RESULT_BYTES;
|
|
73
85
|
/** Run one op through the firewall, after the per-op guards. Never throws. */
|
|
74
86
|
const runOne = async (op) => {
|
|
75
87
|
if (op.tool === 'batch')
|
|
@@ -99,11 +111,26 @@ async function runBatch(rawArgs, deps) {
|
|
|
99
111
|
const results = await mapLimit(ops, concurrency, (op) => runOne(op));
|
|
100
112
|
outcomes = results.map((result) => ({ status: result.isError ? 'error' : 'ok', result }));
|
|
101
113
|
}
|
|
102
|
-
return renderBatch(ops, outcomes, mode);
|
|
114
|
+
return renderBatch(ops, outcomes, mode, maxResultBytes);
|
|
115
|
+
}
|
|
116
|
+
/** Approximate wire size of one content block (base64 image data dominates when present). */
|
|
117
|
+
function blockBytes(block) {
|
|
118
|
+
const b = block;
|
|
119
|
+
if (typeof b.text === 'string')
|
|
120
|
+
return Buffer.byteLength(b.text, 'utf8');
|
|
121
|
+
if (typeof b.data === 'string')
|
|
122
|
+
return b.data.length;
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
/** A one-line stand-in for an op whose blocks were dropped to stay inside the budget. */
|
|
126
|
+
function elidedSummary(index, tool, blocks, bytes) {
|
|
127
|
+
const kinds = [...new Set(blocks.map((b) => b.type))].join('+') || 'none';
|
|
128
|
+
return `--- op ${index} (${tool}) omitted: ${blocks.length} ${kinds} block(s), ~${bytes} bytes — batch result budget reached; re-run this op on its own to see it ---`;
|
|
103
129
|
}
|
|
104
130
|
/** 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
|
-
|
|
131
|
+
* then each executed op's own content blocks (text/images flow through intact),
|
|
132
|
+
* stopping at `budget` bytes so one batch cannot flood the caller's context. */
|
|
133
|
+
function renderBatch(ops, outcomes, mode, budget) {
|
|
107
134
|
const summary = outcomes.map((o, i) => ({ index: i, tool: ops[i].tool, status: o.status }));
|
|
108
135
|
const counts = {
|
|
109
136
|
total: ops.length,
|
|
@@ -111,17 +138,42 @@ function renderBatch(ops, outcomes, mode) {
|
|
|
111
138
|
error: summary.filter((s) => s.status === 'error').length,
|
|
112
139
|
skipped: summary.filter((s) => s.status === 'skipped').length,
|
|
113
140
|
};
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
];
|
|
141
|
+
// Render the payload first so the header can report how much was elided — the
|
|
142
|
+
// caller needs that number to decide whether to re-run anything.
|
|
143
|
+
const body = [];
|
|
144
|
+
let spent = 0;
|
|
145
|
+
let omittedOps = 0;
|
|
146
|
+
let omittedBytes = 0;
|
|
117
147
|
for (let i = 0; i < outcomes.length; i++) {
|
|
118
148
|
const o = outcomes[i];
|
|
119
149
|
if (!o.result)
|
|
120
150
|
continue; // skipped ops carry no payload
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
151
|
+
const blocks = o.result.content;
|
|
152
|
+
const size = blocks.reduce((n, b) => n + blockBytes(b), 0);
|
|
153
|
+
if (spent + size > budget && spent > 0) {
|
|
154
|
+
// `spent > 0` guarantees the first op always gets through: a single op
|
|
155
|
+
// larger than the whole budget is still more useful than an empty batch.
|
|
156
|
+
body.push({ type: 'text', text: elidedSummary(i, ops[i].tool, blocks, size) });
|
|
157
|
+
omittedOps++;
|
|
158
|
+
omittedBytes += size;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
body.push({ type: 'text', text: `--- op ${i} (${ops[i].tool}) ${o.status} ---` });
|
|
162
|
+
for (const block of blocks)
|
|
163
|
+
body.push(block);
|
|
164
|
+
spent += size;
|
|
124
165
|
}
|
|
166
|
+
const header = {
|
|
167
|
+
batch: {
|
|
168
|
+
mode,
|
|
169
|
+
...counts,
|
|
170
|
+
...(omittedOps > 0
|
|
171
|
+
? { omittedOps, omittedBytes, resultBudgetBytes: budget, note: 'some op payloads were omitted to stay within the batch result budget; raise maxResultBytes or re-run those ops individually' }
|
|
172
|
+
: {}),
|
|
173
|
+
},
|
|
174
|
+
results: summary,
|
|
175
|
+
};
|
|
176
|
+
const content = [{ type: 'text', text: JSON.stringify(header, null, 2) }, ...body];
|
|
125
177
|
// The batch ran successfully even if some ops failed; only flag isError when
|
|
126
178
|
// nothing succeeded, so a host sees partial success as success.
|
|
127
179
|
const isError = ops.length > 0 && counts.ok === 0;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/mcp/limits.ts — output size caps for the page-read tools.
|
|
3
|
+
*
|
|
4
|
+
* `eval` has been capped at 256 KB since 0.1 (MAX_EVAL_BYTES) and `screenshot`
|
|
5
|
+
* reports `truncated` with the real height, but the three tools that read page
|
|
6
|
+
* CONTENT — get_html, get_text, read_as_markdown — were unbounded. A single
|
|
7
|
+
* `get_html` on an ordinary content-heavy page can be several megabytes, which is
|
|
8
|
+
* enough to consume an agent's entire context window in one call, and the caller
|
|
9
|
+
* has no way to ask for less.
|
|
10
|
+
*
|
|
11
|
+
* The cap is applied server-side, after the read: the full payload is still
|
|
12
|
+
* written to the task's `results/` directory by the handlers that save artifacts,
|
|
13
|
+
* so nothing is lost on disk — only what crosses into the model's context is
|
|
14
|
+
* bounded.
|
|
15
|
+
*/
|
|
16
|
+
/** Default cap on a single content read, matching the long-standing eval cap. */
|
|
17
|
+
export declare const DEFAULT_MAX_OUTPUT_BYTES: number;
|
|
18
|
+
/** Floor/ceiling for a caller-supplied `maxBytes`. */
|
|
19
|
+
export declare const MIN_OUTPUT_BYTES = 1024;
|
|
20
|
+
export declare const MAX_OUTPUT_BYTES: number;
|
|
21
|
+
export interface Truncation {
|
|
22
|
+
/** The (possibly shortened) text. */
|
|
23
|
+
text: string;
|
|
24
|
+
/** True when `text` is shorter than the input. */
|
|
25
|
+
truncated: boolean;
|
|
26
|
+
/** UTF-8 byte length of the ORIGINAL text. */
|
|
27
|
+
totalBytes: number;
|
|
28
|
+
/** UTF-8 byte length of `text`. */
|
|
29
|
+
returnedBytes: number;
|
|
30
|
+
}
|
|
31
|
+
/** Truncate plain text (or markdown) to a byte budget. */
|
|
32
|
+
export declare function capText(text: string, maxBytes?: number): Truncation;
|
|
33
|
+
/**
|
|
34
|
+
* Truncate HTML to a byte budget, backing up to the last tag boundary.
|
|
35
|
+
*
|
|
36
|
+
* Cutting mid-tag (`<div class="fo`) hands the caller markup that no parser will
|
|
37
|
+
* accept and that an LLM will happily hallucinate the rest of. Ending on a `>`
|
|
38
|
+
* keeps every returned tag complete — the document is still truncated, but every
|
|
39
|
+
* element in it is well-formed up to the cut.
|
|
40
|
+
*/
|
|
41
|
+
export declare function capHtml(html: string, maxBytes?: number): Truncation;
|
|
42
|
+
/** The metadata fields appended to a truncated read's envelope. */
|
|
43
|
+
export declare function truncationMeta(t: Truncation): Record<string, unknown>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* src/mcp/limits.ts — output size caps for the page-read tools.
|
|
4
|
+
*
|
|
5
|
+
* `eval` has been capped at 256 KB since 0.1 (MAX_EVAL_BYTES) and `screenshot`
|
|
6
|
+
* reports `truncated` with the real height, but the three tools that read page
|
|
7
|
+
* CONTENT — get_html, get_text, read_as_markdown — were unbounded. A single
|
|
8
|
+
* `get_html` on an ordinary content-heavy page can be several megabytes, which is
|
|
9
|
+
* enough to consume an agent's entire context window in one call, and the caller
|
|
10
|
+
* has no way to ask for less.
|
|
11
|
+
*
|
|
12
|
+
* The cap is applied server-side, after the read: the full payload is still
|
|
13
|
+
* written to the task's `results/` directory by the handlers that save artifacts,
|
|
14
|
+
* so nothing is lost on disk — only what crosses into the model's context is
|
|
15
|
+
* bounded.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.MAX_OUTPUT_BYTES = exports.MIN_OUTPUT_BYTES = exports.DEFAULT_MAX_OUTPUT_BYTES = void 0;
|
|
19
|
+
exports.capText = capText;
|
|
20
|
+
exports.capHtml = capHtml;
|
|
21
|
+
exports.truncationMeta = truncationMeta;
|
|
22
|
+
/** Default cap on a single content read, matching the long-standing eval cap. */
|
|
23
|
+
exports.DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
|
|
24
|
+
/** Floor/ceiling for a caller-supplied `maxBytes`. */
|
|
25
|
+
exports.MIN_OUTPUT_BYTES = 1024;
|
|
26
|
+
exports.MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
|
|
27
|
+
/**
|
|
28
|
+
* Cut `text` to at most `maxBytes` UTF-8 bytes.
|
|
29
|
+
*
|
|
30
|
+
* `Buffer.subarray` slices bytes, which can land mid-codepoint; decoding back to
|
|
31
|
+
* a string would leave a replacement character at the seam. So the slice is taken
|
|
32
|
+
* and then trimmed back to the last complete character.
|
|
33
|
+
*/
|
|
34
|
+
function sliceUtf8(text, maxBytes) {
|
|
35
|
+
const buf = Buffer.from(text, 'utf8');
|
|
36
|
+
if (buf.byteLength <= maxBytes)
|
|
37
|
+
return text;
|
|
38
|
+
let end = maxBytes;
|
|
39
|
+
// A UTF-8 continuation byte is 10xxxxxx; walk back off the middle of a
|
|
40
|
+
// multi-byte sequence so the decode is clean.
|
|
41
|
+
while (end > 0 && (buf[end] & 0b1100_0000) === 0b1000_0000)
|
|
42
|
+
end--;
|
|
43
|
+
return buf.subarray(0, end).toString('utf8');
|
|
44
|
+
}
|
|
45
|
+
/** Truncate plain text (or markdown) to a byte budget. */
|
|
46
|
+
function capText(text, maxBytes = exports.DEFAULT_MAX_OUTPUT_BYTES) {
|
|
47
|
+
const totalBytes = Buffer.byteLength(text, 'utf8');
|
|
48
|
+
if (totalBytes <= maxBytes) {
|
|
49
|
+
return { text, truncated: false, totalBytes, returnedBytes: totalBytes };
|
|
50
|
+
}
|
|
51
|
+
const cut = sliceUtf8(text, maxBytes);
|
|
52
|
+
return { text: cut, truncated: true, totalBytes, returnedBytes: Buffer.byteLength(cut, 'utf8') };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Truncate HTML to a byte budget, backing up to the last tag boundary.
|
|
56
|
+
*
|
|
57
|
+
* Cutting mid-tag (`<div class="fo`) hands the caller markup that no parser will
|
|
58
|
+
* accept and that an LLM will happily hallucinate the rest of. Ending on a `>`
|
|
59
|
+
* keeps every returned tag complete — the document is still truncated, but every
|
|
60
|
+
* element in it is well-formed up to the cut.
|
|
61
|
+
*/
|
|
62
|
+
function capHtml(html, maxBytes = exports.DEFAULT_MAX_OUTPUT_BYTES) {
|
|
63
|
+
const capped = capText(html, maxBytes);
|
|
64
|
+
if (!capped.truncated)
|
|
65
|
+
return capped;
|
|
66
|
+
const lastClose = capped.text.lastIndexOf('>');
|
|
67
|
+
// Only back up when a boundary exists reasonably near the cut; a single
|
|
68
|
+
// enormous text node has no tag to align to and is better returned as-is.
|
|
69
|
+
if (lastClose > 0) {
|
|
70
|
+
const aligned = capped.text.slice(0, lastClose + 1);
|
|
71
|
+
return { ...capped, text: aligned, returnedBytes: Buffer.byteLength(aligned, 'utf8') };
|
|
72
|
+
}
|
|
73
|
+
return capped;
|
|
74
|
+
}
|
|
75
|
+
/** The metadata fields appended to a truncated read's envelope. */
|
|
76
|
+
function truncationMeta(t) {
|
|
77
|
+
if (!t.truncated)
|
|
78
|
+
return {};
|
|
79
|
+
return {
|
|
80
|
+
truncated: true,
|
|
81
|
+
totalBytes: t.totalBytes,
|
|
82
|
+
returnedBytes: t.returnedBytes,
|
|
83
|
+
truncationNote: `output capped at ${t.returnedBytes} of ${t.totalBytes} bytes; ` +
|
|
84
|
+
'raise `maxBytes` for more, or narrow the read with `selector`',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=limits.js.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/mcp/log.ts — stderr diagnostics, gated by `--log-level`.
|
|
3
|
+
*
|
|
4
|
+
* Lives apart from `server.ts` so the tool layer can log without importing the
|
|
5
|
+
* server module that imports it back. CRITICAL: in stdio mode NOTHING may be
|
|
6
|
+
* written to stdout except the JSON-RPC stream, so every diagnostic here goes to
|
|
7
|
+
* stderr.
|
|
8
|
+
*/
|
|
9
|
+
import type { LogLevel } from '../config';
|
|
10
|
+
/** Apply the CLI's `--log-level`. Call before anything else logs. */
|
|
11
|
+
export declare function setLogLevel(level: LogLevel): void;
|
|
12
|
+
/** The level currently in force (for tests, and for callers gating expensive tracing). */
|
|
13
|
+
export declare function getLogLevel(): LogLevel;
|
|
14
|
+
/** stderr only — never stdout in stdio mode. Suppressed at `--log-level silent`. */
|
|
15
|
+
export declare function logErr(message: string): void;
|
|
16
|
+
/** Verbose tracing: emitted only at `--log-level debug`. */
|
|
17
|
+
export declare function logDebug(message: string): void;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* src/mcp/log.ts — stderr diagnostics, gated by `--log-level`.
|
|
4
|
+
*
|
|
5
|
+
* Lives apart from `server.ts` so the tool layer can log without importing the
|
|
6
|
+
* server module that imports it back. CRITICAL: in stdio mode NOTHING may be
|
|
7
|
+
* written to stdout except the JSON-RPC stream, so every diagnostic here goes to
|
|
8
|
+
* stderr.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.setLogLevel = setLogLevel;
|
|
12
|
+
exports.getLogLevel = getLogLevel;
|
|
13
|
+
exports.logErr = logErr;
|
|
14
|
+
exports.logDebug = logDebug;
|
|
15
|
+
/**
|
|
16
|
+
* Active verbosity, set once from `--log-level` at startup.
|
|
17
|
+
*
|
|
18
|
+
* `silent` suppresses stderr entirely — an editor MCP config that asked for it
|
|
19
|
+
* was getting the noise anyway, because the parsed flag was never consumed.
|
|
20
|
+
* `debug` turns on the wire tracing that `logDebug` guards.
|
|
21
|
+
*/
|
|
22
|
+
let logLevel = 'info';
|
|
23
|
+
/** Apply the CLI's `--log-level`. Call before anything else logs. */
|
|
24
|
+
function setLogLevel(level) {
|
|
25
|
+
logLevel = level;
|
|
26
|
+
}
|
|
27
|
+
/** The level currently in force (for tests, and for callers gating expensive tracing). */
|
|
28
|
+
function getLogLevel() {
|
|
29
|
+
return logLevel;
|
|
30
|
+
}
|
|
31
|
+
/** stderr only — never stdout in stdio mode. Suppressed at `--log-level silent`. */
|
|
32
|
+
function logErr(message) {
|
|
33
|
+
if (logLevel === 'silent')
|
|
34
|
+
return;
|
|
35
|
+
process.stderr.write(`[chrome-mcp] ${message}\n`);
|
|
36
|
+
}
|
|
37
|
+
/** Verbose tracing: emitted only at `--log-level debug`. */
|
|
38
|
+
function logDebug(message) {
|
|
39
|
+
if (logLevel !== 'debug')
|
|
40
|
+
return;
|
|
41
|
+
process.stderr.write(`[chrome-mcp] [debug] ${message}\n`);
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=log.js.map
|
package/dist/src/mcp/server.d.ts
CHANGED
|
@@ -7,8 +7,7 @@
|
|
|
7
7
|
* go to stderr via `logErr`.
|
|
8
8
|
*/
|
|
9
9
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
10
|
-
|
|
11
|
-
export declare function logErr(message: string): void;
|
|
10
|
+
export { getLogLevel, logDebug, logErr, setLogLevel } from './log';
|
|
12
11
|
/** Build a fresh `Server` with the full tool surface registered (no transport). */
|
|
13
12
|
export declare function createServer(version?: string): McpServer;
|
|
14
13
|
/** Start over stdio. Idempotent. */
|
package/dist/src/mcp/server.js
CHANGED
|
@@ -8,38 +8,42 @@
|
|
|
8
8
|
* go to stderr via `logErr`.
|
|
9
9
|
*/
|
|
10
10
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
-
exports.logErr =
|
|
11
|
+
exports.setLogLevel = exports.logErr = exports.logDebug = exports.getLogLevel = void 0;
|
|
12
12
|
exports.createServer = createServer;
|
|
13
13
|
exports.startMcpServer = startMcpServer;
|
|
14
14
|
exports.stopMcpServer = stopMcpServer;
|
|
15
15
|
exports.isMcpServerRunning = isMcpServerRunning;
|
|
16
16
|
const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
17
17
|
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
18
|
+
const log_1 = require("./log");
|
|
18
19
|
const tools_1 = require("./tools");
|
|
20
|
+
// Re-exported so existing callers (and the CLI) keep importing the logger from
|
|
21
|
+
// here; the implementation lives in ./log to avoid a server↔tools import cycle.
|
|
22
|
+
var log_2 = require("./log");
|
|
23
|
+
Object.defineProperty(exports, "getLogLevel", { enumerable: true, get: function () { return log_2.getLogLevel; } });
|
|
24
|
+
Object.defineProperty(exports, "logDebug", { enumerable: true, get: function () { return log_2.logDebug; } });
|
|
25
|
+
Object.defineProperty(exports, "logErr", { enumerable: true, get: function () { return log_2.logErr; } });
|
|
26
|
+
Object.defineProperty(exports, "setLogLevel", { enumerable: true, get: function () { return log_2.setLogLevel; } });
|
|
19
27
|
const SERVER_NAME = 'chrome-mcp';
|
|
20
28
|
const SERVER_VERSION = '0.1.0';
|
|
21
29
|
/** Default version reported when no explicit version is passed in (legacy callers/tests). */
|
|
22
30
|
const DEFAULT_VERSION = SERVER_VERSION;
|
|
23
31
|
let server = null;
|
|
24
32
|
let transport = null;
|
|
25
|
-
/** stderr only — never stdout in stdio mode. */
|
|
26
|
-
function logErr(message) {
|
|
27
|
-
process.stderr.write(`[chrome-mcp] ${message}\n`);
|
|
28
|
-
}
|
|
29
33
|
/** Build a fresh `Server` with the full tool surface registered (no transport). */
|
|
30
34
|
function createServer(version = DEFAULT_VERSION) {
|
|
31
35
|
const srv = new mcp_js_1.McpServer({ name: SERVER_NAME, version }, { capabilities: { tools: {} } });
|
|
32
36
|
(0, tools_1.registerTools)(srv);
|
|
33
37
|
// `McpServer` wraps the low-level `Server`, which owns the `onerror` hook.
|
|
34
38
|
srv.server.onerror = (err) => {
|
|
35
|
-
logErr(`server error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
|
|
39
|
+
(0, log_1.logErr)(`server error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
|
|
36
40
|
};
|
|
37
41
|
return srv;
|
|
38
42
|
}
|
|
39
43
|
/** Start over stdio. Idempotent. */
|
|
40
44
|
async function startMcpServer(version = DEFAULT_VERSION) {
|
|
41
45
|
if (server) {
|
|
42
|
-
logErr('startMcpServer called but already running; ignoring.');
|
|
46
|
+
(0, log_1.logErr)('startMcpServer called but already running; ignoring.');
|
|
43
47
|
return;
|
|
44
48
|
}
|
|
45
49
|
const srv = createServer(version);
|
|
@@ -48,14 +52,14 @@ async function startMcpServer(version = DEFAULT_VERSION) {
|
|
|
48
52
|
await srv.connect(tx);
|
|
49
53
|
}
|
|
50
54
|
catch (err) {
|
|
51
|
-
logErr(`failed to connect stdio transport: ${String(err)}`);
|
|
55
|
+
(0, log_1.logErr)(`failed to connect stdio transport: ${String(err)}`);
|
|
52
56
|
server = null;
|
|
53
57
|
transport = null;
|
|
54
58
|
throw err;
|
|
55
59
|
}
|
|
56
60
|
server = srv;
|
|
57
61
|
transport = tx;
|
|
58
|
-
logErr(`${SERVER_NAME} v${version} connected over stdio.`);
|
|
62
|
+
(0, log_1.logErr)(`${SERVER_NAME} v${version} connected over stdio.`);
|
|
59
63
|
}
|
|
60
64
|
/** Stop and release the transport. Idempotent, best-effort. */
|
|
61
65
|
async function stopMcpServer() {
|
|
@@ -69,15 +73,15 @@ async function stopMcpServer() {
|
|
|
69
73
|
await srv.close();
|
|
70
74
|
}
|
|
71
75
|
catch (err) {
|
|
72
|
-
logErr(`error closing server: ${String(err)}`);
|
|
76
|
+
(0, log_1.logErr)(`error closing server: ${String(err)}`);
|
|
73
77
|
}
|
|
74
78
|
try {
|
|
75
79
|
await tx?.close();
|
|
76
80
|
}
|
|
77
81
|
catch (err) {
|
|
78
|
-
logErr(`error closing transport: ${String(err)}`);
|
|
82
|
+
(0, log_1.logErr)(`error closing transport: ${String(err)}`);
|
|
79
83
|
}
|
|
80
|
-
logErr('MCP server stopped.');
|
|
84
|
+
(0, log_1.logErr)('MCP server stopped.');
|
|
81
85
|
}
|
|
82
86
|
function isMcpServerRunning() {
|
|
83
87
|
return server !== null;
|
package/dist/src/mcp/tools.js
CHANGED
|
@@ -23,8 +23,10 @@ const types_1 = require("../executor/types");
|
|
|
23
23
|
const manager_1 = require("../executor/manager");
|
|
24
24
|
const policy_1 = require("../security/policy");
|
|
25
25
|
const envelopes_1 = require("./envelopes");
|
|
26
|
+
const limits_1 = require("./limits");
|
|
26
27
|
const batch_1 = require("./batch");
|
|
27
28
|
const helpers_1 = require("./helpers");
|
|
29
|
+
const log_1 = require("./log");
|
|
28
30
|
const tasks_1 = require("../bridge/tasks");
|
|
29
31
|
const workspace_1 = require("../bridge/workspace");
|
|
30
32
|
const validators_1 = require("./validators");
|
|
@@ -34,6 +36,10 @@ const TARGET_PROPS = {
|
|
|
34
36
|
ref: zod_1.z.string().describe('Element ref from a prior read (exactly one of selector|ref)').optional(),
|
|
35
37
|
};
|
|
36
38
|
const tabIdField = zod_1.z.string().describe('Target tab id (defaults to the active tab)').optional();
|
|
39
|
+
const maxBytesField = zod_1.z
|
|
40
|
+
.number()
|
|
41
|
+
.describe(`Cap the returned content at this many UTF-8 bytes (default ${limits_1.DEFAULT_MAX_OUTPUT_BYTES}). A truncated result reports truncated/totalBytes/returnedBytes. The full payload is still written to the task's results/ dir.`)
|
|
42
|
+
.optional();
|
|
37
43
|
const waitUntilField = zod_1.z.enum(['load', 'domcontentloaded', 'networkidle']).describe('When to consider navigation done').optional();
|
|
38
44
|
exports.TOOL_DEFINITIONS = [
|
|
39
45
|
{ name: 'tabs_list', description: 'List open browser tabs.', inputSchema: {} },
|
|
@@ -51,15 +57,15 @@ exports.TOOL_DEFINITIONS = [
|
|
|
51
57
|
{ name: 'hover', description: 'Hover over an element.', inputSchema: { ...TARGET_PROPS, tabId: tabIdField } },
|
|
52
58
|
{ name: 'scroll', description: 'Scroll the page or to an element.', inputSchema: { ...TARGET_PROPS, x: zod_1.z.number().optional(), y: zod_1.z.number().optional(), deltaX: zod_1.z.number().optional(), deltaY: zod_1.z.number().optional(), tabId: tabIdField } },
|
|
53
59
|
{ name: 'screenshot', description: 'Capture a PNG screenshot (page or element).', inputSchema: { ...TARGET_PROPS, fullPage: zod_1.z.boolean().optional(), tabId: tabIdField } },
|
|
54
|
-
{ name: 'get_text', description: 'Get visible text of the page or an element.', inputSchema: { ...TARGET_PROPS, tabId: tabIdField } },
|
|
55
|
-
{ name: 'get_html', description: 'Get HTML of the page or an element.', inputSchema: { ...TARGET_PROPS, outer: zod_1.z.boolean().optional(), tabId: tabIdField } },
|
|
60
|
+
{ name: 'get_text', description: 'Get visible text of the page or an element.', inputSchema: { ...TARGET_PROPS, tabId: tabIdField, maxBytes: maxBytesField } },
|
|
61
|
+
{ name: 'get_html', description: 'Get HTML of the page or an element. Output is capped (see maxBytes) and cut at a tag boundary; narrow it with `selector` rather than raising the cap when you can.', inputSchema: { ...TARGET_PROPS, outer: zod_1.z.boolean().optional(), tabId: tabIdField, maxBytes: maxBytesField } },
|
|
56
62
|
{ name: 'snapshot', description: 'Accessibility snapshot: interactive elements with stable refs to target by `ref` (more reliable than guessing CSS selectors).', inputSchema: { interactiveOnly: zod_1.z.boolean().optional(), max: zod_1.z.number().optional(), tabId: tabIdField } },
|
|
57
63
|
{ name: 'get_cookies', description: "Read cookies visible to the tab's URL (or a given url).", inputSchema: { url: zod_1.z.string().optional(), tabId: tabIdField } },
|
|
58
64
|
{ name: 'storage', description: 'Read/write localStorage (or sessionStorage). op: get|set|remove|clear.', inputSchema: { op: zod_1.z.enum(['get', 'set', 'remove', 'clear']), key: zod_1.z.string().optional(), value: zod_1.z.string().optional(), session: zod_1.z.boolean().optional(), tabId: tabIdField } },
|
|
59
65
|
{ name: 'eval', description: 'Evaluate JavaScript in the page (disabled in safe-mode).', inputSchema: { expression: zod_1.z.string(), awaitPromise: zod_1.z.boolean().optional(), tabId: tabIdField } },
|
|
60
66
|
{ name: 'wait_for', description: 'Wait for a selector or text to appear/disappear.', inputSchema: { selector: zod_1.z.string().optional(), textContains: zod_1.z.string().optional(), gone: zod_1.z.boolean().optional(), timeoutMs: zod_1.z.number().optional(), tabId: tabIdField } },
|
|
61
67
|
{ name: 'extract_links', description: 'Extract anchors from the page or a subtree. dedupe=true collapses links sharing an href (nav/footer noise); limit caps the count.', inputSchema: { selector: zod_1.z.string().optional(), sameOriginOnly: zod_1.z.boolean().optional(), dedupe: zod_1.z.boolean().optional(), limit: zod_1.z.number().optional(), tabId: tabIdField } },
|
|
62
|
-
{ name: 'read_as_markdown', description: 'Read the page (or subtree) as readable markdown.', inputSchema: { selector: zod_1.z.string().optional(), tabId: tabIdField } },
|
|
68
|
+
{ name: 'read_as_markdown', description: 'Read the page (or subtree) as readable markdown.', inputSchema: { selector: zod_1.z.string().optional(), tabId: tabIdField, maxBytes: maxBytesField } },
|
|
63
69
|
{ name: 'fill_form', description: 'Fill multiple fields (keyed by selector) and optionally submit.', inputSchema: { fields: zod_1.z.record(zod_1.z.string(), zod_1.z.union([zod_1.z.string(), zod_1.z.boolean()])), submitSelector: zod_1.z.string().optional(), tabId: tabIdField } },
|
|
64
70
|
{ name: 'download_file', description: 'Download a file by URL or from a link element.', inputSchema: { url: zod_1.z.string().optional(), ...TARGET_PROPS, suggestedName: zod_1.z.string().optional(), tabId: tabIdField } },
|
|
65
71
|
{ 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: { ...TARGET_PROPS, files: zod_1.z.array(zod_1.z.string()), tabId: tabIdField } },
|
|
@@ -78,27 +84,38 @@ exports.TOOL_DEFINITIONS = [
|
|
|
78
84
|
mode: zod_1.z.enum(['parallel', 'serial']).describe('Default "parallel".').optional(),
|
|
79
85
|
stopOnError: zod_1.z.boolean().describe('Serial mode only: stop after the first failing op (the rest are skipped).').optional(),
|
|
80
86
|
maxConcurrency: zod_1.z.number().describe('Parallel mode: max ops in flight at once (default 6).').optional(),
|
|
87
|
+
maxResultBytes: zod_1.z.number().describe('Total payload budget across all ops (default 1048576). Ops past the budget are replaced by a one-line summary instead of their content, so a 50-op screenshot/get_html batch cannot flood the caller.').optional(),
|
|
81
88
|
},
|
|
82
89
|
},
|
|
83
90
|
];
|
|
84
|
-
const GATE_CONTEXT = 'cannot resolve the
|
|
91
|
+
const GATE_CONTEXT = 'cannot resolve the target tab URL for the policy gate';
|
|
85
92
|
/**
|
|
86
|
-
* Resolve the URL the policy should be evaluated against
|
|
93
|
+
* Resolve the URL the policy should be evaluated against: the URL of the tab
|
|
94
|
+
* this very call will act on — the explicit `tabId` when the caller gave one,
|
|
95
|
+
* the active tab only when they didn't.
|
|
87
96
|
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
97
|
+
* Gating on the ACTIVE tab regardless of `tabId` is an authorization bypass:
|
|
98
|
+
* park an allowlisted page as active and every `tabId`-addressed read (get_text,
|
|
99
|
+
* get_html, screenshot, eval, …) sails through against a tab whose origin was
|
|
100
|
+
* never checked. The tab that gets touched is the tab that must be authorized.
|
|
101
|
+
*
|
|
102
|
+
* NEVER substitutes a placeholder URL, and never falls back to some *other*
|
|
103
|
+
* tab. If the real origin is unknown, evaluating the policy against a stand-in
|
|
104
|
+
* would silently allow or deny against the wrong origin with no signal to the
|
|
105
|
+
* caller. So a `tabsList` failure (or a browser reporting no tabs at all)
|
|
106
|
+
* propagates — the dispatch firewall renders it as an error carrying the code.
|
|
93
107
|
*
|
|
94
108
|
* Prefers a URL the backend already reported over asking again: the extension
|
|
95
109
|
* rides the tab's landing URL home on every result frame, which is what keeps a
|
|
96
|
-
* gated call to ONE round-trip instead of two.
|
|
110
|
+
* gated call to ONE round-trip instead of two. That cache only ever describes
|
|
111
|
+
* the active tab, so it is bypassed whenever an explicit `tabId` is in play.
|
|
97
112
|
*/
|
|
98
|
-
async function
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
113
|
+
async function gatedUrl(ex, tabId) {
|
|
114
|
+
if (!tabId) {
|
|
115
|
+
const known = ex.cachedActiveUrl?.();
|
|
116
|
+
if (known)
|
|
117
|
+
return known;
|
|
118
|
+
}
|
|
102
119
|
let tabs;
|
|
103
120
|
try {
|
|
104
121
|
tabs = await ex.tabsList();
|
|
@@ -111,33 +128,44 @@ async function activeUrl(ex) {
|
|
|
111
128
|
throw new types_1.ExecutorError(err.code, `${GATE_CONTEXT}: ${err.message}`);
|
|
112
129
|
throw err; // an internal fault, not a browser one — don't relabel it
|
|
113
130
|
}
|
|
114
|
-
|
|
115
|
-
if (!active)
|
|
131
|
+
if (tabs.length === 0) {
|
|
116
132
|
throw new types_1.ExecutorError('TAB_NOT_FOUND', `${GATE_CONTEXT}: the browser reports no open tabs`);
|
|
133
|
+
}
|
|
134
|
+
const target = tabId ? tabs.find((t) => t.tabId === tabId) : tabs.find((t) => t.active);
|
|
135
|
+
if (!target) {
|
|
136
|
+
throw new types_1.ExecutorError('TAB_NOT_FOUND', tabId
|
|
137
|
+
? `${GATE_CONTEXT}: no open tab has id ${tabId} — call tabs_list for the current ids`
|
|
138
|
+
: `${GATE_CONTEXT}: the browser reports open tabs but none active — pass an explicit tabId`);
|
|
139
|
+
}
|
|
117
140
|
// An empty URL is Chrome declining to reveal one (a chrome:// page, or a tab
|
|
118
141
|
// the extension has no host access to) — NOT an origin. Gating on '' would
|
|
119
142
|
// produce a baffling "blocked on " denial that reads like a policy decision.
|
|
120
|
-
if (!
|
|
121
|
-
throw new types_1.ExecutorError('TAB_NOT_FOUND', `${GATE_CONTEXT}: the
|
|
143
|
+
if (!target.url) {
|
|
144
|
+
throw new types_1.ExecutorError('TAB_NOT_FOUND', `${GATE_CONTEXT}: the target tab (id ${target.tabId}) reports no URL. Chrome hides it for ` +
|
|
122
145
|
`internal pages (chrome://, the Web Store) and until the extension has access to that site — ` +
|
|
123
146
|
`switch to a normal page, or open the target site in a new tab.`);
|
|
124
147
|
}
|
|
125
|
-
return
|
|
148
|
+
return target.url;
|
|
126
149
|
}
|
|
127
150
|
/**
|
|
128
|
-
* Policy chokepoint. `
|
|
151
|
+
* Policy chokepoint. `opts.url` is the destination for navigation (it governs
|
|
152
|
+
* instead of any current tab URL); `opts.tabId` is the tab the call will act on,
|
|
153
|
+
* and MUST be threaded through by every URL-gated handler that accepts one —
|
|
154
|
+
* omitting it silently authorizes the call against the active tab instead.
|
|
129
155
|
*
|
|
130
|
-
* Only resolves
|
|
156
|
+
* Only resolves a tab URL for methods whose verdict actually depends on one
|
|
131
157
|
* (`isUrlGated`). Tab management and the capability gates — eval, downloads,
|
|
132
158
|
* uploads, mutations — are decided without any URL, so making them wait on the
|
|
133
159
|
* tab list bought nothing and, worse, made `tab_new` fail exactly when the tab
|
|
134
160
|
* list was unreadable: the one call that could dig you out.
|
|
135
161
|
*/
|
|
136
|
-
async function gate(ctx, method,
|
|
137
|
-
const url =
|
|
162
|
+
async function gate(ctx, method, opts = {}) {
|
|
163
|
+
const url = opts.url ?? ((0, policy_1.isUrlGated)(method) ? await gatedUrl(ctx.ex, opts.tabId) : '');
|
|
138
164
|
(0, policy_1.assertUrlAllowed)(url, method, ctx.policy);
|
|
139
165
|
}
|
|
140
166
|
const tabId = (args) => (0, validators_1.optionalString)(args, 'tabId');
|
|
167
|
+
/** The caller's output cap for a content read, or the default. */
|
|
168
|
+
const maxBytes = (args) => (0, validators_1.optionalNumber)(args, 'maxBytes', { min: limits_1.MIN_OUTPUT_BYTES, max: limits_1.MAX_OUTPUT_BYTES }) ?? limits_1.DEFAULT_MAX_OUTPUT_BYTES;
|
|
141
169
|
const waitUntil = (args) => (0, validators_1.optionalString)(args, 'waitUntil');
|
|
142
170
|
/** Tools that don't act on a single tab (so `tabId` is irrelevant) — exempt from
|
|
143
171
|
* the parallel-batch explicit-tabId requirement. Everything else falls back to
|
|
@@ -180,24 +208,24 @@ exports.TOOL_HANDLERS = {
|
|
|
180
208
|
},
|
|
181
209
|
navigate: async (a, ctx) => {
|
|
182
210
|
const url = (0, validators_1.requireString)(a, 'url');
|
|
183
|
-
await gate(ctx, 'navigate', url);
|
|
211
|
+
await gate(ctx, 'navigate', { url });
|
|
184
212
|
return (0, envelopes_1.jsonResult)(await ctx.ex.navigate({ url, tabId: tabId(a), waitUntil: waitUntil(a) }));
|
|
185
213
|
},
|
|
186
214
|
back: async (a, ctx) => {
|
|
187
|
-
await gate(ctx, 'back');
|
|
215
|
+
await gate(ctx, 'back', { tabId: tabId(a) });
|
|
188
216
|
return (0, envelopes_1.jsonResult)(await ctx.ex.back(tabId(a)));
|
|
189
217
|
},
|
|
190
218
|
forward: async (a, ctx) => {
|
|
191
|
-
await gate(ctx, 'forward');
|
|
219
|
+
await gate(ctx, 'forward', { tabId: tabId(a) });
|
|
192
220
|
return (0, envelopes_1.jsonResult)(await ctx.ex.forward(tabId(a)));
|
|
193
221
|
},
|
|
194
222
|
reload: async (a, ctx) => {
|
|
195
|
-
await gate(ctx, 'reload');
|
|
223
|
+
await gate(ctx, 'reload', { tabId: tabId(a) });
|
|
196
224
|
return (0, envelopes_1.jsonResult)(await ctx.ex.reload({ tabId: tabId(a), waitUntil: waitUntil(a) }));
|
|
197
225
|
},
|
|
198
226
|
click: async (a, ctx) => {
|
|
199
227
|
const t = (0, validators_1.requireTarget)(a);
|
|
200
|
-
await gate(ctx, 'click');
|
|
228
|
+
await gate(ctx, 'click', { tabId: tabId(a) });
|
|
201
229
|
return (0, envelopes_1.jsonResult)(await ctx.ex.click(t, {
|
|
202
230
|
tabId: tabId(a),
|
|
203
231
|
button: (0, validators_1.optionalString)(a, 'button'),
|
|
@@ -207,7 +235,7 @@ exports.TOOL_HANDLERS = {
|
|
|
207
235
|
},
|
|
208
236
|
type: async (a, ctx) => {
|
|
209
237
|
const t = (0, validators_1.requireTarget)(a);
|
|
210
|
-
await gate(ctx, 'type');
|
|
238
|
+
await gate(ctx, 'type', { tabId: tabId(a) });
|
|
211
239
|
return (0, envelopes_1.jsonResult)(await ctx.ex.type(t, (0, validators_1.requireWithinLength)((0, validators_1.requireString)(a, 'text'), 'text', validators_1.MAX_TEXT_LEN), {
|
|
212
240
|
tabId: tabId(a),
|
|
213
241
|
clear: (0, validators_1.optionalBoolean)(a, 'clear'),
|
|
@@ -218,14 +246,14 @@ exports.TOOL_HANDLERS = {
|
|
|
218
246
|
},
|
|
219
247
|
select_option: async (a, ctx) => {
|
|
220
248
|
const t = (0, validators_1.requireTarget)(a);
|
|
221
|
-
await gate(ctx, 'type'); // mutating
|
|
249
|
+
await gate(ctx, 'type', { tabId: tabId(a) }); // mutating
|
|
222
250
|
const values = (0, validators_1.optionalStringArray)(a, 'values');
|
|
223
251
|
if (!values || values.length === 0)
|
|
224
252
|
throw new validators_1.McpToolError('"values" must be a non-empty array of strings');
|
|
225
253
|
return (0, envelopes_1.jsonResult)(await ctx.ex.selectOption(t, values, { tabId: tabId(a) }));
|
|
226
254
|
},
|
|
227
255
|
press: async (a, ctx) => {
|
|
228
|
-
await gate(ctx, 'press');
|
|
256
|
+
await gate(ctx, 'press', { tabId: tabId(a) });
|
|
229
257
|
return (0, envelopes_1.jsonResult)(await ctx.ex.press((0, validators_1.requireString)(a, 'key'), {
|
|
230
258
|
tabId: tabId(a),
|
|
231
259
|
modifiers: (0, validators_1.optionalStringArray)(a, 'modifiers'),
|
|
@@ -233,11 +261,11 @@ exports.TOOL_HANDLERS = {
|
|
|
233
261
|
},
|
|
234
262
|
hover: async (a, ctx) => {
|
|
235
263
|
const t = (0, validators_1.requireTarget)(a);
|
|
236
|
-
await gate(ctx, 'hover');
|
|
264
|
+
await gate(ctx, 'hover', { tabId: tabId(a) });
|
|
237
265
|
return (0, envelopes_1.jsonResult)(await ctx.ex.hover(t, { tabId: tabId(a) }));
|
|
238
266
|
},
|
|
239
267
|
scroll: async (a, ctx) => {
|
|
240
|
-
await gate(ctx, 'scroll');
|
|
268
|
+
await gate(ctx, 'scroll', { tabId: tabId(a) });
|
|
241
269
|
return (0, envelopes_1.jsonResult)(await ctx.ex.scroll({
|
|
242
270
|
tabId: tabId(a),
|
|
243
271
|
x: (0, validators_1.optionalNumber)(a, 'x'),
|
|
@@ -248,7 +276,7 @@ exports.TOOL_HANDLERS = {
|
|
|
248
276
|
}));
|
|
249
277
|
},
|
|
250
278
|
screenshot: async (a, ctx) => {
|
|
251
|
-
await gate(ctx, 'screenshot');
|
|
279
|
+
await gate(ctx, 'screenshot', { tabId: tabId(a) });
|
|
252
280
|
const shot = await ctx.ex.screenshot({
|
|
253
281
|
tabId: tabId(a),
|
|
254
282
|
fullPage: (0, validators_1.optionalBoolean)(a, 'fullPage'),
|
|
@@ -259,17 +287,22 @@ exports.TOOL_HANDLERS = {
|
|
|
259
287
|
return (0, envelopes_1.imageResult)(shot.dataBase64, shot.mimeType, caption);
|
|
260
288
|
},
|
|
261
289
|
get_text: async (a, ctx) => {
|
|
262
|
-
await gate(ctx, 'get_text');
|
|
290
|
+
await gate(ctx, 'get_text', { tabId: tabId(a) });
|
|
263
291
|
const res = await ctx.ex.getText((0, validators_1.optionalTarget)(a), { tabId: tabId(a) });
|
|
292
|
+
// Save the FULL read before capping — the artifact on disk stays lossless;
|
|
293
|
+
// only what crosses into the caller's context is bounded.
|
|
264
294
|
(0, workspace_1.saveResult)('get_text', 'json', JSON.stringify(res, null, 2));
|
|
265
|
-
|
|
295
|
+
const capped = (0, limits_1.capText)(res.text, maxBytes(a));
|
|
296
|
+
return (0, envelopes_1.jsonResult)({ ...res, text: capped.text, ...(0, limits_1.truncationMeta)(capped) });
|
|
266
297
|
},
|
|
267
298
|
get_html: async (a, ctx) => {
|
|
268
|
-
await gate(ctx, 'get_html');
|
|
269
|
-
|
|
299
|
+
await gate(ctx, 'get_html', { tabId: tabId(a) });
|
|
300
|
+
const res = await ctx.ex.getHtml((0, validators_1.optionalTarget)(a), { tabId: tabId(a), outer: (0, validators_1.optionalBoolean)(a, 'outer') });
|
|
301
|
+
const capped = (0, limits_1.capHtml)(res.html, maxBytes(a));
|
|
302
|
+
return (0, envelopes_1.jsonResult)({ ...res, html: capped.text, ...(0, limits_1.truncationMeta)(capped) });
|
|
270
303
|
},
|
|
271
304
|
snapshot: async (a, ctx) => {
|
|
272
|
-
await gate(ctx, 'get_text'); // read of page structure
|
|
305
|
+
await gate(ctx, 'get_text', { tabId: tabId(a) }); // read of page structure
|
|
273
306
|
return (0, envelopes_1.jsonResult)(await ctx.ex.snapshot({
|
|
274
307
|
tabId: tabId(a),
|
|
275
308
|
interactiveOnly: (0, validators_1.optionalBoolean)(a, 'interactiveOnly'),
|
|
@@ -277,13 +310,13 @@ exports.TOOL_HANDLERS = {
|
|
|
277
310
|
}));
|
|
278
311
|
},
|
|
279
312
|
get_cookies: async (a, ctx) => {
|
|
280
|
-
await gate(ctx, 'get_text'); // reads tab-scoped secrets; same domain gate as content reads
|
|
313
|
+
await gate(ctx, 'get_text', { tabId: tabId(a) }); // reads tab-scoped secrets; same domain gate as content reads
|
|
281
314
|
return (0, envelopes_1.jsonResult)(await ctx.ex.getCookies({ tabId: tabId(a), url: (0, validators_1.optionalString)(a, 'url') }));
|
|
282
315
|
},
|
|
283
316
|
storage: async (a, ctx) => {
|
|
284
317
|
const op = (0, validators_1.requireString)(a, 'op');
|
|
285
318
|
// get is a read; set/remove/clear mutate.
|
|
286
|
-
await gate(ctx, op === 'get' ? 'get_text' : 'type');
|
|
319
|
+
await gate(ctx, op === 'get' ? 'get_text' : 'type', { tabId: tabId(a) });
|
|
287
320
|
if ((op === 'set' || op === 'remove') && !(0, validators_1.optionalString)(a, 'key')) {
|
|
288
321
|
throw new validators_1.McpToolError(`storage "${op}" requires a "key"`);
|
|
289
322
|
}
|
|
@@ -296,14 +329,14 @@ exports.TOOL_HANDLERS = {
|
|
|
296
329
|
}));
|
|
297
330
|
},
|
|
298
331
|
eval: async (a, ctx) => {
|
|
299
|
-
await gate(ctx, 'eval');
|
|
332
|
+
await gate(ctx, 'eval', { tabId: tabId(a) });
|
|
300
333
|
return (0, envelopes_1.jsonResult)(await ctx.ex.eval((0, validators_1.requireString)(a, 'expression'), {
|
|
301
334
|
tabId: tabId(a),
|
|
302
335
|
awaitPromise: (0, validators_1.optionalBoolean)(a, 'awaitPromise'),
|
|
303
336
|
}));
|
|
304
337
|
},
|
|
305
338
|
wait_for: async (a, ctx) => {
|
|
306
|
-
await gate(ctx, 'wait_for');
|
|
339
|
+
await gate(ctx, 'wait_for', { tabId: tabId(a) });
|
|
307
340
|
return (0, envelopes_1.jsonResult)(await ctx.ex.waitFor({
|
|
308
341
|
tabId: tabId(a),
|
|
309
342
|
selector: (0, validators_1.optionalString)(a, 'selector'),
|
|
@@ -313,7 +346,7 @@ exports.TOOL_HANDLERS = {
|
|
|
313
346
|
}));
|
|
314
347
|
},
|
|
315
348
|
extract_links: async (a, ctx) => {
|
|
316
|
-
await gate(ctx, 'get_text'); // read of page content
|
|
349
|
+
await gate(ctx, 'get_text', { tabId: tabId(a) }); // read of page content
|
|
317
350
|
const res = await (0, helpers_1.extractLinks)(ctx.ex, {
|
|
318
351
|
selector: (0, validators_1.optionalString)(a, 'selector'),
|
|
319
352
|
sameOriginOnly: (0, validators_1.optionalBoolean)(a, 'sameOriginOnly'),
|
|
@@ -325,13 +358,18 @@ exports.TOOL_HANDLERS = {
|
|
|
325
358
|
return (0, envelopes_1.jsonResult)(res);
|
|
326
359
|
},
|
|
327
360
|
read_as_markdown: async (a, ctx) => {
|
|
328
|
-
await gate(ctx, 'get_text');
|
|
361
|
+
await gate(ctx, 'get_text', { tabId: tabId(a) });
|
|
329
362
|
const md = await (0, helpers_1.readAsMarkdown)(ctx.ex, { selector: (0, validators_1.optionalString)(a, 'selector'), tabId: tabId(a) });
|
|
330
363
|
(0, workspace_1.saveResult)('read_as_markdown', 'md', md);
|
|
331
|
-
|
|
364
|
+
const capped = (0, limits_1.capText)(md, maxBytes(a));
|
|
365
|
+
// Markdown is returned as plain text, so the truncation notice has to ride in
|
|
366
|
+
// the body rather than as sibling JSON fields.
|
|
367
|
+
return (0, envelopes_1.textResult)(capped.truncated
|
|
368
|
+
? `${capped.text}\n\n[truncated: ${capped.returnedBytes} of ${capped.totalBytes} bytes — raise maxBytes or narrow with selector; the full document was saved to the task's results/ dir]`
|
|
369
|
+
: capped.text);
|
|
332
370
|
},
|
|
333
371
|
fill_form: async (a, ctx) => {
|
|
334
|
-
await gate(ctx, 'type'); // mutating
|
|
372
|
+
await gate(ctx, 'type', { tabId: tabId(a) }); // mutating
|
|
335
373
|
const fields = a.fields;
|
|
336
374
|
if (typeof fields !== 'object' || fields === null || Array.isArray(fields)) {
|
|
337
375
|
throw new validators_1.McpToolError('"fields" must be an object mapping selector -> string|boolean');
|
|
@@ -356,7 +394,7 @@ exports.TOOL_HANDLERS = {
|
|
|
356
394
|
},
|
|
357
395
|
upload_file: async (a, ctx) => {
|
|
358
396
|
const t = (0, validators_1.requireTarget)(a);
|
|
359
|
-
await gate(ctx, 'upload_file');
|
|
397
|
+
await gate(ctx, 'upload_file', { tabId: tabId(a) });
|
|
360
398
|
const files = (0, validators_1.optionalStringArray)(a, 'files');
|
|
361
399
|
if (!files || files.length === 0)
|
|
362
400
|
throw new validators_1.McpToolError('"files" must be a non-empty array of absolute local paths');
|
|
@@ -448,6 +486,27 @@ function summarizeArgs(rawArgs) {
|
|
|
448
486
|
function recordHistory(tool, rawArgs, ok, error) {
|
|
449
487
|
(0, workspace_1.appendHistory)({ ts: new Date().toISOString(), tool, args: summarizeArgs(rawArgs), ok, ...(error ? { error } : {}) });
|
|
450
488
|
}
|
|
489
|
+
/**
|
|
490
|
+
* Tools it is safe to re-issue after the extension drops mid-flight.
|
|
491
|
+
*
|
|
492
|
+
* MV3 recycles the extension's service worker on its own schedule, so a command
|
|
493
|
+
* can be in flight when the socket goes away — a fault that has nothing to do
|
|
494
|
+
* with the call and that the user currently fixes by re-issuing the identical
|
|
495
|
+
* request by hand. Only idempotent calls are eligible: repeating a `click` or a
|
|
496
|
+
* `type` could submit a form twice, which is not a cost worth paying to avoid one
|
|
497
|
+
* error message. `navigate` is included because landing on the same URL twice is
|
|
498
|
+
* the same end state.
|
|
499
|
+
*/
|
|
500
|
+
const RETRY_SAFE_TOOLS = new Set([
|
|
501
|
+
'tabs_list', 'chrome_status',
|
|
502
|
+
'get_text', 'get_html', 'snapshot', 'get_cookies',
|
|
503
|
+
'extract_links', 'read_as_markdown', 'screenshot',
|
|
504
|
+
'wait_for', 'navigate', 'reload',
|
|
505
|
+
]);
|
|
506
|
+
/** Whether `err` is the transient bridge fault that a single retry can clear. */
|
|
507
|
+
function isRetryableFault(name, err) {
|
|
508
|
+
return err instanceof types_1.ExecutorError && err.code === 'EXTENSION_DISCONNECTED' && RETRY_SAFE_TOOLS.has(name);
|
|
509
|
+
}
|
|
451
510
|
async function dispatchToolCall(name, rawArgs) {
|
|
452
511
|
const handler = exports.TOOL_HANDLERS[name];
|
|
453
512
|
if (!handler)
|
|
@@ -459,7 +518,21 @@ async function dispatchToolCall(name, rawArgs) {
|
|
|
459
518
|
// Workspace-management tools run server-side and must work even with no
|
|
460
519
|
// browser paired, so they skip the executor readiness check.
|
|
461
520
|
const ex = NO_BACKEND_TOOLS.has(name) ? null : await mgr.ensureReady();
|
|
462
|
-
|
|
521
|
+
let result;
|
|
522
|
+
try {
|
|
523
|
+
result = await handler((0, validators_1.asArgs)(rawArgs), { ex, policy: mgr.policy });
|
|
524
|
+
}
|
|
525
|
+
catch (err) {
|
|
526
|
+
if (!isRetryableFault(name, err))
|
|
527
|
+
throw err;
|
|
528
|
+
// Re-pair (ensureReady resolves the new connection) and try once more. A
|
|
529
|
+
// second failure propagates untouched, so a genuinely unpaired browser
|
|
530
|
+
// still reports EXTENSION_DISCONNECTED rather than retrying forever.
|
|
531
|
+
(0, log_1.logDebug)(`${name}: extension disconnected mid-call; re-pairing and retrying once`);
|
|
532
|
+
const reconnected = await mgr.ensureReady();
|
|
533
|
+
result = await handler((0, validators_1.asArgs)(rawArgs), { ex: reconnected, policy: mgr.policy });
|
|
534
|
+
(0, log_1.logDebug)(`${name}: retry after reconnect succeeded`);
|
|
535
|
+
}
|
|
463
536
|
recordHistory(name, rawArgs, !result.isError);
|
|
464
537
|
return result;
|
|
465
538
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mehmoodqureshi/chrome-mcp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.7",
|
|
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",
|