@mehmoodqureshi/chrome-mcp 0.9.1 → 0.9.3

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.
@@ -18,10 +18,11 @@ const workspace_1 = require("../bridge/workspace");
18
18
  * Deliberately short. It exists to cover back-to-back calls (a `batch`, or an
19
19
  * agent's read → click → read), where the tab demonstrably has not changed
20
20
  * between them. Past that, pay the round-trip. Note the extension re-gates every
21
- * command against the tab's live URL regardless, so this window trades a little
22
- * pre-check precision for half the traffic — never enforcement itself.
21
+ * command against the tab's live URL regardless (fail-closed, authoritative), so
22
+ * this window trades a little pre-check precision for half the traffic — never
23
+ * enforcement itself.
23
24
  */
24
- const ACTIVE_URL_TTL_MS = 1_000;
25
+ const ACTIVE_URL_TTL_MS = 2_000;
25
26
  /** Flatten frame options into the params a wire command carries. */
26
27
  function frameParams(o) {
27
28
  if (!o)
@@ -88,6 +89,11 @@ class ExtensionExecutor {
88
89
  cachedActiveUrl() {
89
90
  return this.bridge.lastActiveUrl(this.activeProfile(), ACTIVE_URL_TTL_MS);
90
91
  }
92
+ /** A specific tab's URL as last reported (by a result for that tab, or by a
93
+ * `tabs_list`), if fresh enough to gate against. */
94
+ cachedTabUrl(tabId) {
95
+ return this.bridge.lastTabUrl(this.activeProfile(), tabId, ACTIVE_URL_TTL_MS);
96
+ }
91
97
  // -- tabs ---------------------------------------------------------------
92
98
  async tabsList() {
93
99
  return (await this.send('tabs_list', {}));
@@ -145,7 +151,7 @@ class ExtensionExecutor {
145
151
  return (await this.send('get_html', { ...targetParams(t), ...frameParams(opts), outer: opts?.outer }, { tabId: opts?.tabId }));
146
152
  }
147
153
  async snapshot(opts) {
148
- return (await this.send('snapshot', { interactiveOnly: opts?.interactiveOnly, max: opts?.max, ...frameParams(opts) }, { tabId: opts?.tabId }));
154
+ return (await this.send('snapshot', { interactiveOnly: opts?.interactiveOnly, max: opts?.max, locator: opts?.locator, ...frameParams(opts) }, { tabId: opts?.tabId }));
149
155
  }
150
156
  async getCookies(opts) {
151
157
  return (await this.send('get_cookies', { url: opts?.url }, { tabId: opts?.tabId }));
@@ -154,7 +160,14 @@ class ExtensionExecutor {
154
160
  return (await this.send('storage', { op: args.op, key: args.key, value: args.value, session: args.session }, { tabId: args.tabId }));
155
161
  }
156
162
  async screenshot(opts) {
157
- return (await this.send('screenshot', { fullPage: opts?.fullPage, ...targetParams(opts?.target), ...frameParams(opts) }, { tabId: opts?.tabId }));
163
+ return (await this.send('screenshot', {
164
+ fullPage: opts?.fullPage,
165
+ format: opts?.format,
166
+ quality: opts?.quality,
167
+ scale: opts?.scale,
168
+ ...targetParams(opts?.target),
169
+ ...frameParams(opts),
170
+ }, { tabId: opts?.tabId }));
158
171
  }
159
172
  async eval(expression, opts) {
160
173
  const result = (await this.send('eval', { expression, awaitPromise: opts?.awaitPromise, ...frameParams(opts) }, { tabId: opts?.tabId }));
@@ -54,6 +54,15 @@ export interface StubOptions {
54
54
  snapshotNodes?: SnapshotNode[];
55
55
  /** Frames the stub reports for `frames_list`. */
56
56
  frames?: FrameInfo[];
57
+ /** Applied after any action or history move: models a click/submit/back that
58
+ * lands the tab on a different page (e.g. a redirect to a sign-in wall). */
59
+ afterAction?: {
60
+ url?: string;
61
+ nodes?: SnapshotNode[];
62
+ };
63
+ /** When true, `waitFor` rejects with TIMEOUT - the error a wait on a page
64
+ * that silently became a login form used to surface. */
65
+ waitForTimesOut?: boolean;
57
66
  /** What the in-page observers return. Absent = the hook is not installed,
58
67
  * which is the case the tools must report clearly rather than as an empty list. */
59
68
  observers?: ObserverReadResult;
@@ -76,6 +85,10 @@ export declare class StubExecutor implements Executor {
76
85
  snapshotNodes: SnapshotNode[];
77
86
  private readonly frames;
78
87
  private readonly observerState?;
88
+ private readonly afterAction?;
89
+ private readonly waitForTimesOut;
90
+ /** How many snapshots were taken - the auth guard must cost none when off. */
91
+ snapshotCalls: number;
79
92
  /** The last observer args received, so a test can assert what was requested. */
80
93
  lastObserverArgs?: ObserverArgs;
81
94
  /** How many times the gate actually asked for the tab list — the round-trip
@@ -110,6 +123,8 @@ export declare class StubExecutor implements Executor {
110
123
  back(): Promise<NavResult>;
111
124
  forward(): Promise<NavResult>;
112
125
  reload(): Promise<NavResult>;
126
+ /** Move the stub tab to the configured post-action page, if any. */
127
+ private landed;
113
128
  click(): Promise<ActionOk>;
114
129
  type(): Promise<ActionOk>;
115
130
  fill(): Promise<ActionOk>;
@@ -32,6 +32,10 @@ class StubExecutor {
32
32
  snapshotNodes;
33
33
  frames;
34
34
  observerState;
35
+ afterAction;
36
+ waitForTimesOut;
37
+ /** How many snapshots were taken - the auth guard must cost none when off. */
38
+ snapshotCalls = 0;
35
39
  /** The last observer args received, so a test can assert what was requested. */
36
40
  lastObserverArgs;
37
41
  /** How many times the gate actually asked for the tab list — the round-trip
@@ -40,6 +44,8 @@ class StubExecutor {
40
44
  ready = false;
41
45
  constructor(opts = {}) {
42
46
  this.url = opts.activeUrl ?? 'about:blank';
47
+ this.afterAction = opts.afterAction;
48
+ this.waitForTimesOut = opts.waitForTimesOut ?? false;
43
49
  this.evalThrows = opts.evalThrows ?? false;
44
50
  this.tabsListThrows = opts.tabsListThrows ?? false;
45
51
  this.noTabs = opts.noTabs ?? false;
@@ -133,15 +139,28 @@ class StubExecutor {
133
139
  return { url: args.url, title: 'Stub Page', httpStatus: 200 };
134
140
  }
135
141
  async back() {
142
+ this.landed();
136
143
  return { url: this.url, title: 'Stub Page' };
137
144
  }
138
145
  async forward() {
146
+ this.landed();
139
147
  return { url: this.url, title: 'Stub Page' };
140
148
  }
141
149
  async reload() {
150
+ this.landed();
142
151
  return { url: this.url, title: 'Stub Page' };
143
152
  }
153
+ /** Move the stub tab to the configured post-action page, if any. */
154
+ landed() {
155
+ if (!this.afterAction)
156
+ return;
157
+ if (this.afterAction.url !== undefined)
158
+ this.url = this.afterAction.url;
159
+ if (this.afterAction.nodes !== undefined)
160
+ this.snapshotNodes = this.afterAction.nodes;
161
+ }
144
162
  async click() {
163
+ this.landed();
145
164
  return ok;
146
165
  }
147
166
  async type() {
@@ -149,18 +168,22 @@ class StubExecutor {
149
168
  this.remainingWriteDisconnects--;
150
169
  throw new types_1.ExecutorError('EXTENSION_DISCONNECTED', 'stub: service worker recycled mid-command');
151
170
  }
171
+ this.landed();
152
172
  return ok;
153
173
  }
154
174
  async fill() {
175
+ this.landed();
155
176
  return ok;
156
177
  }
157
178
  async press() {
179
+ this.landed();
158
180
  return ok;
159
181
  }
160
182
  async hover() {
161
183
  return ok;
162
184
  }
163
185
  async selectOption() {
186
+ this.landed();
164
187
  return ok;
165
188
  }
166
189
  async scroll() {
@@ -175,6 +198,7 @@ class StubExecutor {
175
198
  return { html: this.htmlPayload };
176
199
  }
177
200
  async snapshot() {
201
+ this.snapshotCalls++;
178
202
  return { url: this.url, title: 'Stub Page', nodes: this.snapshotNodes, truncated: false };
179
203
  }
180
204
  async getCookies() {
@@ -195,6 +219,8 @@ class StubExecutor {
195
219
  return { ok: true, value: 'stub-value', type: 'string' };
196
220
  }
197
221
  async waitFor() {
222
+ if (this.waitForTimesOut)
223
+ throw new types_1.ExecutorError('TIMEOUT', 'stub: wait_for timed out');
198
224
  return { matched: true, waitedMs: 0 };
199
225
  }
200
226
  async download(args) {
@@ -111,9 +111,18 @@ export interface WaitResult {
111
111
  export interface ActionOk {
112
112
  ok: true;
113
113
  }
114
+ export type ScreenshotFormat = 'png' | 'jpeg';
115
+ /** Encoding knobs every backend accepts. All optional; see shared/screenshot.ts for defaults. */
116
+ export interface ScreenshotEncoding {
117
+ format?: ScreenshotFormat;
118
+ /** JPEG only, 1-100. */
119
+ quality?: number;
120
+ /** Output pixels per CSS pixel (1 = CSS size, 2 = device pixels on a Retina display). */
121
+ scale?: number;
122
+ }
114
123
  export interface ScreenshotResult {
115
124
  dataBase64: string;
116
- mimeType: 'image/png';
125
+ mimeType: 'image/png' | 'image/jpeg';
117
126
  width: number;
118
127
  height: number;
119
128
  /** fullPage capture exceeded the height cap; `fullHeight` reports the real size. */
@@ -153,6 +162,13 @@ export interface SnapshotResult {
153
162
  title: string;
154
163
  nodes: SnapshotNode[];
155
164
  truncated: boolean;
165
+ /** Locator mode, no match: what the page had of that role (for the error message). */
166
+ nearby?: string[];
167
+ }
168
+ /** A role/name query the page resolves itself (see shared/snapshot.ts). */
169
+ export interface SnapshotLocator {
170
+ role?: string;
171
+ name?: string;
156
172
  }
157
173
  export interface CookieItem {
158
174
  name: string;
@@ -205,6 +221,12 @@ export interface Executor {
205
221
  * report cheaply simply omit it.
206
222
  */
207
223
  cachedActiveUrl?(): string | null;
224
+ /**
225
+ * Same idea for an explicitly-targeted tab: its URL if the backend already
226
+ * knows it recently enough (the extension reports it on every result for that
227
+ * tab, and a `tabsList` reports it for every tab). Null → resolve properly.
228
+ */
229
+ cachedTabUrl?(tabId: TabId): string | null;
208
230
  tabsList(): Promise<TabInfo[]>;
209
231
  tabSelect(tabId: TabId): Promise<TabInfo>;
210
232
  /** Open a tab. `active` (default true) focuses it; pass false to open in the background. */
@@ -279,6 +301,7 @@ export interface Executor {
279
301
  tabId?: TabId;
280
302
  interactiveOnly?: boolean;
281
303
  max?: number;
304
+ locator?: SnapshotLocator;
282
305
  } & FrameOpts): Promise<SnapshotResult>;
283
306
  /** Read cookies visible to the active tab's URL (or a given url). */
284
307
  getCookies(opts?: {
@@ -299,7 +322,7 @@ export interface Executor {
299
322
  tabId?: TabId;
300
323
  fullPage?: boolean;
301
324
  target?: Target;
302
- } & FrameOpts): Promise<ScreenshotResult>;
325
+ } & ScreenshotEncoding & FrameOpts): Promise<ScreenshotResult>;
303
326
  eval(expression: string, opts?: {
304
327
  tabId?: TabId;
305
328
  awaitPromise?: boolean;
@@ -349,7 +372,9 @@ export interface Executor {
349
372
  * (which only carries codes that originate inside the extension); these extra
350
373
  * codes describe failures on the server half (no backend, launch failed, etc.).
351
374
  */
352
- export type ExecutorErrorCodeLocal = 'NO_BACKEND' | 'EXTENSION_DISCONNECTED' | 'TIMEOUT' | 'TAB_NOT_FOUND' | 'STALE_TAB' | 'SELECTOR_NOT_FOUND' | 'REF_EXPIRED' | 'EVAL_FAILED' | 'LAUNCH_FAILED' | 'DETACHED' | 'TARGET_GONE' | 'POLICY_DENIED' | 'DEVTOOLS_OPEN' | 'DOWNLOAD_FAILED' | 'UPLOAD_FAILED' | 'FRAME_NOT_FOUND' | 'OBSERVERS_DISABLED' | 'UNSUPPORTED' | 'BACKPRESSURE';
375
+ export type ExecutorErrorCodeLocal = 'NO_BACKEND' | 'EXTENSION_DISCONNECTED' | 'TIMEOUT' | 'TAB_NOT_FOUND' | 'STALE_TAB' | 'SELECTOR_NOT_FOUND' | 'REF_EXPIRED' | 'EVAL_FAILED' | 'LAUNCH_FAILED' | 'DETACHED' | 'TARGET_GONE' | 'POLICY_DENIED' | 'DEVTOOLS_OPEN' | 'DOWNLOAD_FAILED' | 'UPLOAD_FAILED' | 'FRAME_NOT_FOUND' | 'OBSERVERS_DISABLED' | 'UNSUPPORTED' | 'BACKPRESSURE'
376
+ /** The page is a sign-in wall (session expired mid-run). Raised only when the caller opts in via `failOnAuthWall`. */
377
+ | 'AUTH_REQUIRED';
353
378
  export declare class ExecutorError extends Error {
354
379
  readonly code: ExecutorErrorCodeLocal;
355
380
  constructor(code: ExecutorErrorCodeLocal, message: string);
@@ -9,10 +9,13 @@
9
9
  * redeploy.
10
10
  *
11
11
  * A locator closes that: `{ role: 'button', name: 'Sign in' }` resolves through
12
- * one snapshot, server-side, and the caller never sees the tree. Matching runs
13
- * strongest-first (exact, then case-insensitive, then contains) so an
14
- * unambiguous name wins outright, and an ambiguous one fails loudly with the
15
- * candidates rather than silently clicking the first row.
12
+ * one snapshot and the caller never sees the tree. The page does the matching
13
+ * itself (`collectSnapshot` with a locator returns only the strongest-tier
14
+ * hits, and stamps refs on those alone), so what crosses the bridge is a
15
+ * handful of nodes rather than 400; this module re-scores them — same tiers:
16
+ * exact, then case-insensitive, then prefix, then contains — so an unambiguous
17
+ * name wins outright and an ambiguous one fails loudly with the candidates
18
+ * rather than silently clicking the first row.
16
19
  */
17
20
  import type { Executor, SnapshotNode, Target } from '../executor/types';
18
21
  export interface Locator {
@@ -10,10 +10,13 @@
10
10
  * redeploy.
11
11
  *
12
12
  * A locator closes that: `{ role: 'button', name: 'Sign in' }` resolves through
13
- * one snapshot, server-side, and the caller never sees the tree. Matching runs
14
- * strongest-first (exact, then case-insensitive, then contains) so an
15
- * unambiguous name wins outright, and an ambiguous one fails loudly with the
16
- * candidates rather than silently clicking the first row.
13
+ * one snapshot and the caller never sees the tree. The page does the matching
14
+ * itself (`collectSnapshot` with a locator returns only the strongest-tier
15
+ * hits, and stamps refs on those alone), so what crosses the bridge is a
16
+ * handful of nodes rather than 400; this module re-scores them — same tiers:
17
+ * exact, then case-insensitive, then prefix, then contains — so an unambiguous
18
+ * name wins outright and an ambiguous one fails loudly with the candidates
19
+ * rather than silently clicking the first row.
17
20
  */
18
21
  Object.defineProperty(exports, "__esModule", { value: true });
19
22
  exports.hasLocator = hasLocator;
@@ -60,6 +63,7 @@ async function resolveLocator(ex, loc, opts = {}) {
60
63
  tabId: opts.tabId,
61
64
  interactiveOnly: false,
62
65
  max: 400,
66
+ locator: want,
63
67
  frameId: opts.frameId,
64
68
  allFrames: opts.allFrames,
65
69
  });
@@ -79,10 +83,13 @@ async function resolveLocator(ex, loc, opts = {}) {
79
83
  }
80
84
  const describe = (n) => `${n.role} "${n.name}"`;
81
85
  if (winners.length === 0) {
82
- const sample = snap.nodes
83
- .filter((n) => !want.role || norm(n.role) === norm(want.role))
84
- .slice(0, 8)
85
- .map(describe);
86
+ // A page that scored in place reports what it had of that role as `nearby`;
87
+ // a backend that returned the full tree leaves it to us.
88
+ const sample = snap.nearby ??
89
+ snap.nodes
90
+ .filter((n) => !want.role || norm(n.role) === norm(want.role))
91
+ .slice(0, 8)
92
+ .map(describe);
86
93
  throw new validators_1.McpToolError(`no element matches ${JSON.stringify(want)}. ` +
87
94
  (sample.length
88
95
  ? `Closest by role: ${sample.join(', ')}. `