@mehmoodqureshi/chrome-mcp 0.6.6 → 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) {
@@ -32,6 +32,22 @@ export interface StubOptions {
32
32
  /** When true, tabs exist but none is flagged active — the case the gate used to
33
33
  * paper over by silently gating against `tabs[0]`. */
34
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;
35
51
  }
36
52
  export declare class StubExecutor implements Executor {
37
53
  readonly backend: BackendKind;
@@ -39,6 +55,10 @@ export declare class StubExecutor implements Executor {
39
55
  private readonly evalThrows;
40
56
  private readonly tabsListThrows;
41
57
  private readonly noTabs;
58
+ private readonly textPayload;
59
+ private readonly htmlPayload;
60
+ private remainingDisconnects;
61
+ private remainingWriteDisconnects;
42
62
  private readonly blankTabUrl;
43
63
  private readonly cached;
44
64
  private readonly backgroundTabs;
@@ -48,6 +68,12 @@ export declare class StubExecutor implements Executor {
48
68
  tabsListCalls: number;
49
69
  private ready;
50
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;
51
77
  private tab;
52
78
  cachedActiveUrl(): string | null;
53
79
  status(): ExecutorStatus;
@@ -20,6 +20,10 @@ 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;
25
29
  backgroundTabs;
@@ -37,6 +41,25 @@ class StubExecutor {
37
41
  this.cached = opts.cachedUrl ?? null;
38
42
  this.backgroundTabs = opts.backgroundTabs ?? [];
39
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;
40
63
  }
41
64
  tab() {
42
65
  return {
@@ -113,6 +136,10 @@ class StubExecutor {
113
136
  return ok;
114
137
  }
115
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
+ }
116
143
  return ok;
117
144
  }
118
145
  async fill() {
@@ -131,10 +158,12 @@ class StubExecutor {
131
158
  return ok;
132
159
  }
133
160
  async getText(_t) {
134
- return { text: 'stub text', ref: 'el_stub_1' };
161
+ this.maybeDisconnect();
162
+ return { text: this.textPayload, ref: 'el_stub_1' };
135
163
  }
136
164
  async getHtml() {
137
- return { html: '<html><body><a href="https://example.com">Example</a></body></html>' };
165
+ this.maybeDisconnect();
166
+ return { html: this.htmlPayload };
138
167
  }
139
168
  async snapshot() {
140
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,6 +84,7 @@ 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
  ];
@@ -157,6 +164,8 @@ async function gate(ctx, method, opts = {}) {
157
164
  (0, policy_1.assertUrlAllowed)(url, method, ctx.policy);
158
165
  }
159
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;
160
169
  const waitUntil = (args) => (0, validators_1.optionalString)(args, 'waitUntil');
161
170
  /** Tools that don't act on a single tab (so `tabId` is irrelevant) — exempt from
162
171
  * the parallel-batch explicit-tabId requirement. Everything else falls back to
@@ -280,12 +289,17 @@ exports.TOOL_HANDLERS = {
280
289
  get_text: async (a, ctx) => {
281
290
  await gate(ctx, 'get_text', { tabId: tabId(a) });
282
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.
283
294
  (0, workspace_1.saveResult)('get_text', 'json', JSON.stringify(res, null, 2));
284
- 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) });
285
297
  },
286
298
  get_html: async (a, ctx) => {
287
299
  await gate(ctx, 'get_html', { tabId: tabId(a) });
288
- return (0, envelopes_1.jsonResult)(await ctx.ex.getHtml((0, validators_1.optionalTarget)(a), { tabId: tabId(a), outer: (0, validators_1.optionalBoolean)(a, 'outer') }));
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) });
289
303
  },
290
304
  snapshot: async (a, ctx) => {
291
305
  await gate(ctx, 'get_text', { tabId: tabId(a) }); // read of page structure
@@ -347,7 +361,12 @@ exports.TOOL_HANDLERS = {
347
361
  await gate(ctx, 'get_text', { tabId: tabId(a) });
348
362
  const md = await (0, helpers_1.readAsMarkdown)(ctx.ex, { selector: (0, validators_1.optionalString)(a, 'selector'), tabId: tabId(a) });
349
363
  (0, workspace_1.saveResult)('read_as_markdown', 'md', md);
350
- 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);
351
370
  },
352
371
  fill_form: async (a, ctx) => {
353
372
  await gate(ctx, 'type', { tabId: tabId(a) }); // mutating
@@ -467,6 +486,27 @@ function summarizeArgs(rawArgs) {
467
486
  function recordHistory(tool, rawArgs, ok, error) {
468
487
  (0, workspace_1.appendHistory)({ ts: new Date().toISOString(), tool, args: summarizeArgs(rawArgs), ok, ...(error ? { error } : {}) });
469
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
+ }
470
510
  async function dispatchToolCall(name, rawArgs) {
471
511
  const handler = exports.TOOL_HANDLERS[name];
472
512
  if (!handler)
@@ -478,7 +518,21 @@ async function dispatchToolCall(name, rawArgs) {
478
518
  // Workspace-management tools run server-side and must work even with no
479
519
  // browser paired, so they skip the executor readiness check.
480
520
  const ex = NO_BACKEND_TOOLS.has(name) ? null : await mgr.ensureReady();
481
- 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
+ }
482
536
  recordHistory(name, rawArgs, !result.isError);
483
537
  return result;
484
538
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mehmoodqureshi/chrome-mcp",
3
- "version": "0.6.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",