@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 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: true,
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
- return this.noTabs ? [] : [this.tab()];
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
- return { text: 'stub text', ref: 'el_stub_1' };
161
+ this.maybeDisconnect();
162
+ return { text: this.textPayload, ref: 'el_stub_1' };
120
163
  }
121
164
  async getHtml() {
122
- return { html: '<html><body><a href="https://example.com">Example</a></body></html>' };
165
+ this.maybeDisconnect();
166
+ return { html: this.htmlPayload };
123
167
  }
124
168
  async snapshot() {
125
169
  return {
@@ -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
- function renderBatch(ops, outcomes, mode) {
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
- const content = [
115
- { type: 'text', text: JSON.stringify({ batch: { mode, ...counts }, results: summary }, null, 2) },
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
- content.push({ type: 'text', text: `--- op ${i} (${ops[i].tool}) ${o.status} ---` });
122
- for (const block of o.result.content)
123
- content.push(block);
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
@@ -7,8 +7,7 @@
7
7
  * go to stderr via `logErr`.
8
8
  */
9
9
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
- /** stderr only never stdout in stdio mode. */
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. */
@@ -8,38 +8,42 @@
8
8
  * go to stderr via `logErr`.
9
9
  */
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
- exports.logErr = 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;
@@ -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 active tab URL for the policy gate';
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 (the active tab).
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
- * NEVER substitutes a placeholder URL. If the tab list can't be read, the real
89
- * origin is unknown, and evaluating the policy against a fabricated URL would
90
- * silently allow or deny against the wrong origin with no signal to the caller.
91
- * So a `tabsList` failure (or a browser reporting no tabs at all) propagates
92
- * the dispatch firewall renders it as a structured error carrying the code.
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 activeUrl(ex) {
99
- const known = ex.cachedActiveUrl?.();
100
- if (known)
101
- return known;
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
- const active = tabs.find((t) => t.active) ?? tabs[0];
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 (!active.url) {
121
- throw new types_1.ExecutorError('TAB_NOT_FOUND', `${GATE_CONTEXT}: the active tab (id ${active.tabId}) reports no URL. Chrome hides it for ` +
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 active.url;
148
+ return target.url;
126
149
  }
127
150
  /**
128
- * Policy chokepoint. `urlOverride` is the destination for navigation.
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 the active URL for methods whose verdict actually depends on one
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, urlOverride) {
137
- const url = urlOverride ?? ((0, policy_1.isUrlGated)(method) ? await activeUrl(ctx.ex) : '');
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
- return (0, envelopes_1.jsonResult)(res);
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
- return (0, envelopes_1.jsonResult)(await ctx.ex.getHtml((0, validators_1.optionalTarget)(a), { tabId: tabId(a), outer: (0, validators_1.optionalBoolean)(a, 'outer') }));
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
- return (0, envelopes_1.textResult)(md);
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
- const result = await handler((0, validators_1.asArgs)(rawArgs), { ex, policy: mgr.policy });
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.5",
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",