@mehmoodqureshi/chrome-mcp 0.1.0 → 0.2.0
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/README.md +38 -5
- package/dist/shared/protocol.d.ts +1 -1
- package/dist/shared/protocol.js +4 -0
- package/dist/shared/snapshot.d.ts +26 -0
- package/dist/shared/snapshot.js +92 -0
- package/dist/src/bridge/auth.d.ts +23 -1
- package/dist/src/bridge/auth.js +68 -1
- package/dist/src/cli.js +7 -1
- package/dist/src/config.d.ts +2 -0
- package/dist/src/config.js +8 -0
- package/dist/src/executor/cdp-executor.d.ts +24 -1
- package/dist/src/executor/cdp-executor.js +72 -1
- package/dist/src/executor/extension-executor.d.ts +24 -1
- package/dist/src/executor/extension-executor.js +14 -2
- package/dist/src/executor/stub-executor.d.ts +10 -1
- package/dist/src/executor/stub-executor.js +19 -0
- package/dist/src/executor/types.d.ts +60 -0
- package/dist/src/mcp/tools.js +43 -2
- package/extension-dist/background.js +307 -25
- package/extension-dist/manifest.json +2 -2
- package/extension-dist/options.js +6 -3
- package/package.json +2 -2
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* 2. Drives the dispatch/policy/envelope tests with deterministic, canned
|
|
8
8
|
* values (and a couple of forced-failure switches).
|
|
9
9
|
*/
|
|
10
|
-
import { type ActionOk, type BackendKind, type DownloadResult, type EvalResult, type Executor, type ExecutorStatus, type NavResult, type ScreenshotResult, type TabId, type TabInfo, type Target, type WaitResult } from './types';
|
|
10
|
+
import { type ActionOk, type BackendKind, type CookieItem, type DownloadResult, type EvalResult, type Executor, type ExecutorStatus, type NavResult, type ScreenshotResult, type SnapshotResult, type StorageOp, type StorageResult, type TabId, type TabInfo, type Target, type WaitResult } from './types';
|
|
11
11
|
export interface StubOptions {
|
|
12
12
|
/** URL of the (single) active tab — used to exercise the domain policy gate. */
|
|
13
13
|
activeUrl?: string;
|
|
@@ -43,6 +43,7 @@ export declare class StubExecutor implements Executor {
|
|
|
43
43
|
fill(): Promise<ActionOk>;
|
|
44
44
|
press(): Promise<ActionOk>;
|
|
45
45
|
hover(): Promise<ActionOk>;
|
|
46
|
+
selectOption(): Promise<ActionOk>;
|
|
46
47
|
scroll(): Promise<ActionOk>;
|
|
47
48
|
getText(_t?: Target): Promise<{
|
|
48
49
|
text: string;
|
|
@@ -51,6 +52,14 @@ export declare class StubExecutor implements Executor {
|
|
|
51
52
|
getHtml(): Promise<{
|
|
52
53
|
html: string;
|
|
53
54
|
}>;
|
|
55
|
+
snapshot(): Promise<SnapshotResult>;
|
|
56
|
+
getCookies(): Promise<{
|
|
57
|
+
cookies: CookieItem[];
|
|
58
|
+
}>;
|
|
59
|
+
storage(args: {
|
|
60
|
+
op: StorageOp;
|
|
61
|
+
key?: string;
|
|
62
|
+
}): Promise<StorageResult>;
|
|
54
63
|
screenshot(): Promise<ScreenshotResult>;
|
|
55
64
|
eval(expression: string): Promise<EvalResult>;
|
|
56
65
|
waitFor(): Promise<WaitResult>;
|
|
@@ -85,6 +85,9 @@ class StubExecutor {
|
|
|
85
85
|
async hover() {
|
|
86
86
|
return ok;
|
|
87
87
|
}
|
|
88
|
+
async selectOption() {
|
|
89
|
+
return ok;
|
|
90
|
+
}
|
|
88
91
|
async scroll() {
|
|
89
92
|
return ok;
|
|
90
93
|
}
|
|
@@ -94,6 +97,22 @@ class StubExecutor {
|
|
|
94
97
|
async getHtml() {
|
|
95
98
|
return { html: '<html><body><a href="https://example.com">Example</a></body></html>' };
|
|
96
99
|
}
|
|
100
|
+
async snapshot() {
|
|
101
|
+
return {
|
|
102
|
+
url: this.url,
|
|
103
|
+
title: 'Stub Page',
|
|
104
|
+
nodes: [{ ref: 'e1', role: 'link', name: 'Example', tag: 'a' }],
|
|
105
|
+
truncated: false,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
async getCookies() {
|
|
109
|
+
return { cookies: [{ name: 'stub', value: '1', domain: 'example.com', path: '/', secure: true, httpOnly: false }] };
|
|
110
|
+
}
|
|
111
|
+
async storage(args) {
|
|
112
|
+
if (args.op === 'get')
|
|
113
|
+
return { ok: true, value: args.key ? 'stub-value' : null, entries: args.key ? undefined : { k: 'stub-value' } };
|
|
114
|
+
return { ok: true };
|
|
115
|
+
}
|
|
97
116
|
async screenshot() {
|
|
98
117
|
return { dataBase64: TINY_PNG, mimeType: 'image/png', width: 1, height: 1, truncated: false };
|
|
99
118
|
}
|
|
@@ -75,6 +75,39 @@ export interface DownloadResult {
|
|
|
75
75
|
mimeType?: string;
|
|
76
76
|
suggestedName?: string;
|
|
77
77
|
}
|
|
78
|
+
/** One interactive/landmark element in an accessibility snapshot. `ref` is stable until the tab navigates. */
|
|
79
|
+
export interface SnapshotNode {
|
|
80
|
+
ref: string;
|
|
81
|
+
role: string;
|
|
82
|
+
name: string;
|
|
83
|
+
tag: string;
|
|
84
|
+
value?: string;
|
|
85
|
+
disabled?: boolean;
|
|
86
|
+
checked?: boolean;
|
|
87
|
+
}
|
|
88
|
+
export interface SnapshotResult {
|
|
89
|
+
url: string;
|
|
90
|
+
title: string;
|
|
91
|
+
nodes: SnapshotNode[];
|
|
92
|
+
truncated: boolean;
|
|
93
|
+
}
|
|
94
|
+
export interface CookieItem {
|
|
95
|
+
name: string;
|
|
96
|
+
value: string;
|
|
97
|
+
domain: string;
|
|
98
|
+
path: string;
|
|
99
|
+
secure: boolean;
|
|
100
|
+
httpOnly: boolean;
|
|
101
|
+
expires?: number;
|
|
102
|
+
}
|
|
103
|
+
export type StorageOp = 'get' | 'set' | 'remove' | 'clear';
|
|
104
|
+
export interface StorageResult {
|
|
105
|
+
ok: boolean;
|
|
106
|
+
/** For `get`: the value (or null if absent). For others: omitted. */
|
|
107
|
+
value?: string | null;
|
|
108
|
+
/** For a keyless `get`: the whole store as a flat object. */
|
|
109
|
+
entries?: Record<string, string>;
|
|
110
|
+
}
|
|
78
111
|
export interface ExecutorStatus {
|
|
79
112
|
ready: boolean;
|
|
80
113
|
backend: BackendKind | null;
|
|
@@ -119,12 +152,18 @@ export interface Executor {
|
|
|
119
152
|
tabId?: TabId;
|
|
120
153
|
button?: MouseButton;
|
|
121
154
|
clickCount?: number;
|
|
155
|
+
trusted?: boolean;
|
|
122
156
|
}): Promise<ActionOk>;
|
|
123
157
|
type(t: Target, text: string, opts?: {
|
|
124
158
|
tabId?: TabId;
|
|
125
159
|
clear?: boolean;
|
|
126
160
|
pressEnter?: boolean;
|
|
127
161
|
keyEvents?: boolean;
|
|
162
|
+
trusted?: boolean;
|
|
163
|
+
}): Promise<ActionOk>;
|
|
164
|
+
/** Choose option(s) of a <select> by value or visible label. */
|
|
165
|
+
selectOption(t: Target, values: string[], opts?: {
|
|
166
|
+
tabId?: TabId;
|
|
128
167
|
}): Promise<ActionOk>;
|
|
129
168
|
/** Value-set + input/change events (used by fill_form). */
|
|
130
169
|
fill(t: Target, value: string, opts?: {
|
|
@@ -157,6 +196,27 @@ export interface Executor {
|
|
|
157
196
|
}): Promise<{
|
|
158
197
|
html: string;
|
|
159
198
|
}>;
|
|
199
|
+
/** Accessibility snapshot: interactive/landmark elements with stable refs the model can target. */
|
|
200
|
+
snapshot(opts?: {
|
|
201
|
+
tabId?: TabId;
|
|
202
|
+
interactiveOnly?: boolean;
|
|
203
|
+
max?: number;
|
|
204
|
+
}): Promise<SnapshotResult>;
|
|
205
|
+
/** Read cookies visible to the active tab's URL (or a given url). */
|
|
206
|
+
getCookies(opts?: {
|
|
207
|
+
tabId?: TabId;
|
|
208
|
+
url?: string;
|
|
209
|
+
}): Promise<{
|
|
210
|
+
cookies: CookieItem[];
|
|
211
|
+
}>;
|
|
212
|
+
/** localStorage/sessionStorage get/set/remove/clear for the active tab. */
|
|
213
|
+
storage(args: {
|
|
214
|
+
op: StorageOp;
|
|
215
|
+
key?: string;
|
|
216
|
+
value?: string;
|
|
217
|
+
session?: boolean;
|
|
218
|
+
tabId?: TabId;
|
|
219
|
+
}): Promise<StorageResult>;
|
|
160
220
|
screenshot(opts?: {
|
|
161
221
|
tabId?: TabId;
|
|
162
222
|
fullPage?: boolean;
|
package/dist/src/mcp/tools.js
CHANGED
|
@@ -41,14 +41,18 @@ exports.TOOL_DEFINITIONS = [
|
|
|
41
41
|
{ name: 'back', description: 'Go back in history.', inputSchema: obj({ tabId: { type: 'string' } }) },
|
|
42
42
|
{ name: 'forward', description: 'Go forward in history.', inputSchema: obj({ tabId: { type: 'string' } }) },
|
|
43
43
|
{ name: 'reload', description: 'Reload the active (or given) tab.', inputSchema: obj({ tabId: { type: 'string' }, waitUntil: { type: 'string', enum: ['load', 'domcontentloaded', 'networkidle'] } }) },
|
|
44
|
-
{ name: 'click', description: 'Click an element.', inputSchema: obj({ ...TARGET_PROPS, tabId: { type: 'string' }, button: { type: 'string', enum: ['left', 'right', 'middle'] }, clickCount: { type: 'number' } }) },
|
|
45
|
-
{ name: 'type', description: 'Type text into an element.', inputSchema: obj({ ...TARGET_PROPS, text: { type: 'string' }, tabId: { type: 'string' }, clear: { type: 'boolean' }, pressEnter: { type: 'boolean' }, keyEvents: { type: 'boolean' } }, ['text']) },
|
|
44
|
+
{ name: 'click', description: 'Click an element (target by selector or a snapshot ref). trusted=true uses real OS-level input.', inputSchema: obj({ ...TARGET_PROPS, tabId: { type: 'string' }, button: { type: 'string', enum: ['left', 'right', 'middle'] }, clickCount: { type: 'number' }, trusted: { type: 'boolean' } }) },
|
|
45
|
+
{ name: 'type', description: 'Type text into an element. trusted=true sends real keystrokes (works on React/Vue controlled inputs).', inputSchema: obj({ ...TARGET_PROPS, text: { type: 'string' }, tabId: { type: 'string' }, clear: { type: 'boolean' }, pressEnter: { type: 'boolean' }, keyEvents: { type: 'boolean' }, trusted: { type: 'boolean' } }, ['text']) },
|
|
46
|
+
{ name: 'select_option', description: 'Select option(s) of a <select> by value or visible label.', inputSchema: obj({ ...TARGET_PROPS, values: { type: 'array', items: { type: 'string' } }, tabId: { type: 'string' } }, ['values']) },
|
|
46
47
|
{ name: 'press', description: 'Press a key (with optional modifiers).', inputSchema: obj({ key: { type: 'string' }, modifiers: { type: 'array', items: { type: 'string' } }, tabId: { type: 'string' } }, ['key']) },
|
|
47
48
|
{ name: 'hover', description: 'Hover over an element.', inputSchema: obj({ ...TARGET_PROPS, tabId: { type: 'string' } }) },
|
|
48
49
|
{ name: 'scroll', description: 'Scroll the page or to an element.', inputSchema: obj({ ...TARGET_PROPS, x: { type: 'number' }, y: { type: 'number' }, deltaX: { type: 'number' }, deltaY: { type: 'number' }, tabId: { type: 'string' } }) },
|
|
49
50
|
{ name: 'screenshot', description: 'Capture a PNG screenshot (page or element).', inputSchema: obj({ ...TARGET_PROPS, fullPage: { type: 'boolean' }, tabId: { type: 'string' } }) },
|
|
50
51
|
{ name: 'get_text', description: 'Get visible text of the page or an element.', inputSchema: obj({ ...TARGET_PROPS, tabId: { type: 'string' } }) },
|
|
51
52
|
{ name: 'get_html', description: 'Get HTML of the page or an element.', inputSchema: obj({ ...TARGET_PROPS, outer: { type: 'boolean' }, tabId: { type: 'string' } }) },
|
|
53
|
+
{ name: 'snapshot', description: 'Accessibility snapshot: interactive elements with stable refs to target by `ref` (more reliable than guessing CSS selectors).', inputSchema: obj({ interactiveOnly: { type: 'boolean' }, max: { type: 'number' }, tabId: { type: 'string' } }) },
|
|
54
|
+
{ name: 'get_cookies', description: "Read cookies visible to the tab's URL (or a given url).", inputSchema: obj({ url: { type: 'string' }, tabId: { type: 'string' } }) },
|
|
55
|
+
{ name: 'storage', description: 'Read/write localStorage (or sessionStorage). op: get|set|remove|clear.', inputSchema: obj({ op: { type: 'string', enum: ['get', 'set', 'remove', 'clear'] }, key: { type: 'string' }, value: { type: 'string' }, session: { type: 'boolean' }, tabId: { type: 'string' } }, ['op']) },
|
|
52
56
|
{ name: 'eval', description: 'Evaluate JavaScript in the page (disabled in safe-mode).', inputSchema: obj({ expression: { type: 'string' }, awaitPromise: { type: 'boolean' }, tabId: { type: 'string' } }, ['expression']) },
|
|
53
57
|
{ name: 'wait_for', description: 'Wait for a selector or text to appear/disappear.', inputSchema: obj({ selector: { type: 'string' }, textContains: { type: 'string' }, gone: { type: 'boolean' }, timeoutMs: { type: 'number' }, tabId: { type: 'string' } }) },
|
|
54
58
|
{ name: 'extract_links', description: 'Extract anchors from the page or a subtree.', inputSchema: obj({ selector: { type: 'string' }, sameOriginOnly: { type: 'boolean' }, tabId: { type: 'string' } }) },
|
|
@@ -112,6 +116,7 @@ exports.TOOL_HANDLERS = {
|
|
|
112
116
|
tabId: tabId(a),
|
|
113
117
|
button: (0, validators_1.optionalString)(a, 'button'),
|
|
114
118
|
clickCount: (0, validators_1.optionalNumber)(a, 'clickCount', { min: 1, max: 3 }),
|
|
119
|
+
trusted: (0, validators_1.optionalBoolean)(a, 'trusted'),
|
|
115
120
|
}));
|
|
116
121
|
},
|
|
117
122
|
type: async (a, ctx) => {
|
|
@@ -122,8 +127,17 @@ exports.TOOL_HANDLERS = {
|
|
|
122
127
|
clear: (0, validators_1.optionalBoolean)(a, 'clear'),
|
|
123
128
|
pressEnter: (0, validators_1.optionalBoolean)(a, 'pressEnter'),
|
|
124
129
|
keyEvents: (0, validators_1.optionalBoolean)(a, 'keyEvents'),
|
|
130
|
+
trusted: (0, validators_1.optionalBoolean)(a, 'trusted'),
|
|
125
131
|
}));
|
|
126
132
|
},
|
|
133
|
+
select_option: async (a, ctx) => {
|
|
134
|
+
const t = (0, validators_1.requireTarget)(a);
|
|
135
|
+
await gate(ctx, 'type'); // mutating
|
|
136
|
+
const values = (0, validators_1.optionalStringArray)(a, 'values');
|
|
137
|
+
if (!values || values.length === 0)
|
|
138
|
+
throw new validators_1.McpToolError('"values" must be a non-empty array of strings');
|
|
139
|
+
return (0, envelopes_1.jsonResult)(await ctx.ex.selectOption(t, values, { tabId: tabId(a) }));
|
|
140
|
+
},
|
|
127
141
|
press: async (a, ctx) => {
|
|
128
142
|
await gate(ctx, 'press');
|
|
129
143
|
return (0, envelopes_1.jsonResult)(await ctx.ex.press((0, validators_1.requireString)(a, 'key'), {
|
|
@@ -165,6 +179,33 @@ exports.TOOL_HANDLERS = {
|
|
|
165
179
|
await gate(ctx, 'get_html');
|
|
166
180
|
return (0, envelopes_1.jsonResult)(await ctx.ex.getHtml((0, validators_1.optionalTarget)(a), { tabId: tabId(a), outer: (0, validators_1.optionalBoolean)(a, 'outer') }));
|
|
167
181
|
},
|
|
182
|
+
snapshot: async (a, ctx) => {
|
|
183
|
+
await gate(ctx, 'get_text'); // read of page structure
|
|
184
|
+
return (0, envelopes_1.jsonResult)(await ctx.ex.snapshot({
|
|
185
|
+
tabId: tabId(a),
|
|
186
|
+
interactiveOnly: (0, validators_1.optionalBoolean)(a, 'interactiveOnly'),
|
|
187
|
+
max: (0, validators_1.optionalNumber)(a, 'max', { min: 1, max: 1000 }),
|
|
188
|
+
}));
|
|
189
|
+
},
|
|
190
|
+
get_cookies: async (a, ctx) => {
|
|
191
|
+
await gate(ctx, 'get_text'); // reads tab-scoped secrets; same domain gate as content reads
|
|
192
|
+
return (0, envelopes_1.jsonResult)(await ctx.ex.getCookies({ tabId: tabId(a), url: (0, validators_1.optionalString)(a, 'url') }));
|
|
193
|
+
},
|
|
194
|
+
storage: async (a, ctx) => {
|
|
195
|
+
const op = (0, validators_1.requireString)(a, 'op');
|
|
196
|
+
// get is a read; set/remove/clear mutate.
|
|
197
|
+
await gate(ctx, op === 'get' ? 'get_text' : 'type');
|
|
198
|
+
if ((op === 'set' || op === 'remove') && !(0, validators_1.optionalString)(a, 'key')) {
|
|
199
|
+
throw new validators_1.McpToolError(`storage "${op}" requires a "key"`);
|
|
200
|
+
}
|
|
201
|
+
return (0, envelopes_1.jsonResult)(await ctx.ex.storage({
|
|
202
|
+
op,
|
|
203
|
+
key: (0, validators_1.optionalString)(a, 'key'),
|
|
204
|
+
value: (0, validators_1.optionalString)(a, 'value'),
|
|
205
|
+
session: (0, validators_1.optionalBoolean)(a, 'session'),
|
|
206
|
+
tabId: tabId(a),
|
|
207
|
+
}));
|
|
208
|
+
},
|
|
168
209
|
eval: async (a, ctx) => {
|
|
169
210
|
await gate(ctx, 'eval');
|
|
170
211
|
return (0, envelopes_1.jsonResult)(await ctx.ex.eval((0, validators_1.requireString)(a, 'expression'), {
|
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
"screenshot",
|
|
20
20
|
"get_text",
|
|
21
21
|
"get_html",
|
|
22
|
+
"snapshot",
|
|
23
|
+
"select_option",
|
|
24
|
+
"get_cookies",
|
|
25
|
+
"storage",
|
|
22
26
|
"eval",
|
|
23
27
|
"wait_for",
|
|
24
28
|
"download_file",
|
|
@@ -161,6 +165,68 @@
|
|
|
161
165
|
return n;
|
|
162
166
|
}
|
|
163
167
|
|
|
168
|
+
// shared/snapshot.ts
|
|
169
|
+
function collectSnapshot(interactiveOnly = true, max = 200) {
|
|
170
|
+
const INTERACTIVE = "a[href],button,input,select,textarea,[role=button],[role=link],[role=tab],[role=checkbox],[role=radio],[role=menuitem],[role=option],[role=switch],[contenteditable=true],[onclick]";
|
|
171
|
+
const LANDMARK = "h1,h2,h3,[role=heading],nav,main,header,footer,[role=navigation]";
|
|
172
|
+
const sel = interactiveOnly ? INTERACTIVE : `${INTERACTIVE},${LANDMARK}`;
|
|
173
|
+
const visible = (el) => {
|
|
174
|
+
const r = el.getBoundingClientRect();
|
|
175
|
+
if (r.width === 0 && r.height === 0) return false;
|
|
176
|
+
const s = window.getComputedStyle(el);
|
|
177
|
+
return s.visibility !== "hidden" && s.display !== "none";
|
|
178
|
+
};
|
|
179
|
+
const accName = (el) => {
|
|
180
|
+
const aria = el.getAttribute("aria-label");
|
|
181
|
+
if (aria) return aria.trim();
|
|
182
|
+
const labelledby = el.getAttribute("aria-labelledby");
|
|
183
|
+
if (labelledby) {
|
|
184
|
+
const t = labelledby.split(/\s+/).map((id) => document.getElementById(id)?.innerText ?? "").join(" ").trim();
|
|
185
|
+
if (t) return t;
|
|
186
|
+
}
|
|
187
|
+
const ph = el.getAttribute("placeholder");
|
|
188
|
+
if (ph) return ph.trim();
|
|
189
|
+
const title = el.getAttribute("title");
|
|
190
|
+
if (title) return title.trim();
|
|
191
|
+
const text = el.innerText ?? "";
|
|
192
|
+
if (text) return text.replace(/\s+/g, " ").trim().slice(0, 120);
|
|
193
|
+
const alt = el.querySelector("img[alt]")?.getAttribute("alt");
|
|
194
|
+
return (alt ?? "").trim();
|
|
195
|
+
};
|
|
196
|
+
const roleOf = (el) => {
|
|
197
|
+
const explicit = el.getAttribute("role");
|
|
198
|
+
if (explicit) return explicit;
|
|
199
|
+
const tag = el.tagName.toLowerCase();
|
|
200
|
+
if (tag === "a") return "link";
|
|
201
|
+
if (tag === "button") return "button";
|
|
202
|
+
if (tag === "select") return "combobox";
|
|
203
|
+
if (tag === "textarea") return "textbox";
|
|
204
|
+
if (tag === "input") {
|
|
205
|
+
const t = el.type;
|
|
206
|
+
if (t === "checkbox") return "checkbox";
|
|
207
|
+
if (t === "radio") return "radio";
|
|
208
|
+
if (t === "button" || t === "submit") return "button";
|
|
209
|
+
return "textbox";
|
|
210
|
+
}
|
|
211
|
+
return tag;
|
|
212
|
+
};
|
|
213
|
+
const els = Array.from(document.querySelectorAll(sel)).filter(visible);
|
|
214
|
+
const nodes = [];
|
|
215
|
+
let n = 0;
|
|
216
|
+
for (const el of els) {
|
|
217
|
+
if (nodes.length >= max) break;
|
|
218
|
+
const ref = `e${++n}`;
|
|
219
|
+
el.setAttribute("data-mcp-ref", ref);
|
|
220
|
+
const node = { ref, role: roleOf(el), name: accName(el), tag: el.tagName.toLowerCase() };
|
|
221
|
+
const v = el.value;
|
|
222
|
+
if (typeof v === "string" && v) node.value = v.slice(0, 200);
|
|
223
|
+
if (el.disabled) node.disabled = true;
|
|
224
|
+
if (el.checked) node.checked = true;
|
|
225
|
+
nodes.push(node);
|
|
226
|
+
}
|
|
227
|
+
return { url: location.href, title: document.title, nodes, truncated: els.length > nodes.length };
|
|
228
|
+
}
|
|
229
|
+
|
|
164
230
|
// extension/src/sw/executor.ts
|
|
165
231
|
var CmdError = class extends Error {
|
|
166
232
|
constructor(code, message) {
|
|
@@ -210,9 +276,73 @@
|
|
|
210
276
|
await delay(100);
|
|
211
277
|
}
|
|
212
278
|
}
|
|
213
|
-
function
|
|
279
|
+
function resolveSelector(cmd) {
|
|
214
280
|
const s = cmd.params.selector;
|
|
215
|
-
|
|
281
|
+
if (typeof s === "string" && s.length > 0) return s;
|
|
282
|
+
const ref = cmd.params.ref;
|
|
283
|
+
if (typeof ref === "string" && ref.length > 0) return `[data-mcp-ref="${ref.replace(/["\\]/g, "\\$&")}"]`;
|
|
284
|
+
return void 0;
|
|
285
|
+
}
|
|
286
|
+
function selectorOf(cmd) {
|
|
287
|
+
return resolveSelector(cmd);
|
|
288
|
+
}
|
|
289
|
+
async function waitForSelector(tabId, selector, timeoutMs = 5e3) {
|
|
290
|
+
const start = Date.now();
|
|
291
|
+
for (; ; ) {
|
|
292
|
+
const present = await execInTab(tabId, (s) => !!document.querySelector(s), [selector]);
|
|
293
|
+
if (present) return true;
|
|
294
|
+
if (Date.now() - start > timeoutMs) return false;
|
|
295
|
+
await delay(120);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
async function withDebugger(tabId, fn) {
|
|
299
|
+
const target = { tabId };
|
|
300
|
+
await chrome.debugger.attach(target, "1.3");
|
|
301
|
+
try {
|
|
302
|
+
return await fn(target);
|
|
303
|
+
} finally {
|
|
304
|
+
await chrome.debugger.detach(target).catch(() => void 0);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
async function trustedType(tabId, selector, text, clear) {
|
|
308
|
+
const focused = await execInTab(
|
|
309
|
+
tabId,
|
|
310
|
+
(s, doClear) => {
|
|
311
|
+
const el = document.querySelector(s);
|
|
312
|
+
if (!el) return false;
|
|
313
|
+
el.focus();
|
|
314
|
+
if (doClear) {
|
|
315
|
+
const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), "value")?.set;
|
|
316
|
+
setter ? setter.call(el, "") : el.value = "";
|
|
317
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
318
|
+
}
|
|
319
|
+
return true;
|
|
320
|
+
},
|
|
321
|
+
[selector, clear]
|
|
322
|
+
);
|
|
323
|
+
if (!focused) return false;
|
|
324
|
+
await withDebugger(tabId, (t) => chrome.debugger.sendCommand(t, "Input.insertText", { text }));
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
async function trustedClick(tabId, selector) {
|
|
328
|
+
const pt = await execInTab(
|
|
329
|
+
tabId,
|
|
330
|
+
(s) => {
|
|
331
|
+
const el = document.querySelector(s);
|
|
332
|
+
if (!el) return null;
|
|
333
|
+
el.scrollIntoView({ block: "center", inline: "center" });
|
|
334
|
+
const r = el.getBoundingClientRect();
|
|
335
|
+
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
|
336
|
+
},
|
|
337
|
+
[selector]
|
|
338
|
+
);
|
|
339
|
+
if (!pt) return false;
|
|
340
|
+
await withDebugger(tabId, async (t) => {
|
|
341
|
+
const base = { x: pt.x, y: pt.y, button: "left", clickCount: 1 };
|
|
342
|
+
await chrome.debugger.sendCommand(t, "Input.dispatchMouseEvent", { type: "mousePressed", buttons: 1, ...base });
|
|
343
|
+
await chrome.debugger.sendCommand(t, "Input.dispatchMouseEvent", { type: "mouseReleased", buttons: 0, ...base });
|
|
344
|
+
});
|
|
345
|
+
return true;
|
|
216
346
|
}
|
|
217
347
|
async function tabInfo(tab, index = 0) {
|
|
218
348
|
return {
|
|
@@ -240,6 +370,10 @@
|
|
|
240
370
|
"screenshot",
|
|
241
371
|
"get_text",
|
|
242
372
|
"get_html",
|
|
373
|
+
"snapshot",
|
|
374
|
+
"select_option",
|
|
375
|
+
"get_cookies",
|
|
376
|
+
"storage",
|
|
243
377
|
"eval",
|
|
244
378
|
"wait_for",
|
|
245
379
|
"download_file",
|
|
@@ -325,42 +459,155 @@
|
|
|
325
459
|
);
|
|
326
460
|
return { html: html ?? "" };
|
|
327
461
|
}
|
|
462
|
+
// -- accessibility snapshot (tags elements with data-mcp-ref so refs work) --
|
|
463
|
+
case "snapshot": {
|
|
464
|
+
const id = await targetTab(cmd);
|
|
465
|
+
const interactiveOnly = cmd.params.interactiveOnly !== false;
|
|
466
|
+
const max = typeof cmd.params.max === "number" ? cmd.params.max : 200;
|
|
467
|
+
const raw = await execInTab(
|
|
468
|
+
id,
|
|
469
|
+
collectSnapshot,
|
|
470
|
+
[interactiveOnly, max]
|
|
471
|
+
);
|
|
472
|
+
return raw ?? { url: "", title: "", nodes: [], truncated: false };
|
|
473
|
+
}
|
|
474
|
+
// -- <select> option(s) by value or visible label --
|
|
475
|
+
case "select_option": {
|
|
476
|
+
const id = await targetTab(cmd);
|
|
477
|
+
const sel = requireSelector(cmd);
|
|
478
|
+
const values = Array.isArray(cmd.params.values) ? cmd.params.values.map(String) : [];
|
|
479
|
+
await waitForSelector(id, sel);
|
|
480
|
+
const matched = await execInTab(
|
|
481
|
+
id,
|
|
482
|
+
(s, vals) => {
|
|
483
|
+
const el = document.querySelector(s);
|
|
484
|
+
if (!el || !el.options) return false;
|
|
485
|
+
const set = new Set(vals);
|
|
486
|
+
let hit = false;
|
|
487
|
+
for (const opt of Array.from(el.options)) {
|
|
488
|
+
const on = set.has(opt.value) || set.has(opt.label) || set.has(opt.text);
|
|
489
|
+
opt.selected = on;
|
|
490
|
+
if (on) hit = true;
|
|
491
|
+
}
|
|
492
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
493
|
+
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
494
|
+
return hit;
|
|
495
|
+
},
|
|
496
|
+
[sel, values]
|
|
497
|
+
);
|
|
498
|
+
if (!matched) throw new CmdError("SELECTOR_NOT_FOUND", `no <select> option matched for ${sel}`);
|
|
499
|
+
return { ok: true };
|
|
500
|
+
}
|
|
501
|
+
// -- cookies for the tab's URL (chrome.cookies; needs "cookies" permission) --
|
|
502
|
+
case "get_cookies": {
|
|
503
|
+
const id = await targetTab(cmd);
|
|
504
|
+
const t = await chrome.tabs.get(id);
|
|
505
|
+
const url = typeof cmd.params.url === "string" ? cmd.params.url : t.url;
|
|
506
|
+
if (!url) throw new CmdError("BAD_ARGS", "no url to read cookies for");
|
|
507
|
+
const cookies = await chrome.cookies.getAll({ url });
|
|
508
|
+
return {
|
|
509
|
+
cookies: cookies.map((c) => ({
|
|
510
|
+
name: c.name,
|
|
511
|
+
value: c.value,
|
|
512
|
+
domain: c.domain,
|
|
513
|
+
path: c.path,
|
|
514
|
+
secure: c.secure,
|
|
515
|
+
httpOnly: c.httpOnly,
|
|
516
|
+
expires: c.expirationDate
|
|
517
|
+
}))
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
// -- localStorage / sessionStorage (isolated world) --
|
|
521
|
+
case "storage": {
|
|
522
|
+
const id = await targetTab(cmd);
|
|
523
|
+
const op = String(cmd.params.op);
|
|
524
|
+
const key = typeof cmd.params.key === "string" ? cmd.params.key : null;
|
|
525
|
+
const value = typeof cmd.params.value === "string" ? cmd.params.value : null;
|
|
526
|
+
const session = cmd.params.session === true;
|
|
527
|
+
const res = await execInTab(
|
|
528
|
+
id,
|
|
529
|
+
(o, k, v, s) => {
|
|
530
|
+
const store = s ? window.sessionStorage : window.localStorage;
|
|
531
|
+
if (o === "set") {
|
|
532
|
+
store.setItem(String(k), String(v ?? ""));
|
|
533
|
+
return { ok: true };
|
|
534
|
+
}
|
|
535
|
+
if (o === "remove") {
|
|
536
|
+
store.removeItem(String(k));
|
|
537
|
+
return { ok: true };
|
|
538
|
+
}
|
|
539
|
+
if (o === "clear") {
|
|
540
|
+
store.clear();
|
|
541
|
+
return { ok: true };
|
|
542
|
+
}
|
|
543
|
+
if (k) return { ok: true, value: store.getItem(k) };
|
|
544
|
+
const entries = {};
|
|
545
|
+
for (let i = 0; i < store.length; i++) {
|
|
546
|
+
const kk = store.key(i);
|
|
547
|
+
if (kk) entries[kk] = store.getItem(kk) ?? "";
|
|
548
|
+
}
|
|
549
|
+
return { ok: true, entries };
|
|
550
|
+
},
|
|
551
|
+
[op, key, value, session]
|
|
552
|
+
);
|
|
553
|
+
return res ?? { ok: false };
|
|
554
|
+
}
|
|
328
555
|
// -- interaction (synthetic events in the isolated world) --
|
|
329
556
|
case "click": {
|
|
330
557
|
const id = await targetTab(cmd);
|
|
558
|
+
const sel = requireSelector(cmd);
|
|
559
|
+
if (!await waitForSelector(id, sel)) throw new CmdError("SELECTOR_NOT_FOUND", `no element for selector: ${sel}`);
|
|
560
|
+
if (cmd.params.trusted === true) {
|
|
561
|
+
if (!await trustedClick(id, sel)) throw new CmdError("SELECTOR_NOT_FOUND", `no element for selector: ${sel}`);
|
|
562
|
+
return { ok: true };
|
|
563
|
+
}
|
|
331
564
|
const found = await execInTab(
|
|
332
565
|
id,
|
|
333
|
-
(
|
|
334
|
-
const el = document.querySelector(
|
|
566
|
+
(s) => {
|
|
567
|
+
const el = document.querySelector(s);
|
|
335
568
|
if (!el) return false;
|
|
336
569
|
el.scrollIntoView({ block: "center" });
|
|
337
570
|
el.click();
|
|
338
571
|
return true;
|
|
339
572
|
},
|
|
340
|
-
[
|
|
573
|
+
[sel]
|
|
341
574
|
);
|
|
342
|
-
if (!found) throw new CmdError("SELECTOR_NOT_FOUND", `no element for selector: ${
|
|
575
|
+
if (!found) throw new CmdError("SELECTOR_NOT_FOUND", `no element for selector: ${sel}`);
|
|
343
576
|
return { ok: true };
|
|
344
577
|
}
|
|
345
578
|
case "type": {
|
|
346
579
|
const id = await targetTab(cmd);
|
|
580
|
+
const sel = requireSelector(cmd);
|
|
347
581
|
const text = String(cmd.params.text ?? "");
|
|
348
582
|
const clear = cmd.params.clear === true;
|
|
583
|
+
if (!await waitForSelector(id, sel)) throw new CmdError("SELECTOR_NOT_FOUND", `no element for selector: ${sel}`);
|
|
584
|
+
if (cmd.params.trusted === true) {
|
|
585
|
+
if (!await trustedType(id, sel, text, clear)) throw new CmdError("SELECTOR_NOT_FOUND", `no element for selector: ${sel}`);
|
|
586
|
+
if (cmd.params.pressEnter === true) {
|
|
587
|
+
await withDebugger(id, async (t) => {
|
|
588
|
+
for (const type of ["keyDown", "keyUp"]) {
|
|
589
|
+
await chrome.debugger.sendCommand(t, "Input.dispatchKeyEvent", { type, key: "Enter", code: "Enter", windowsVirtualKeyCode: 13 });
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
return { ok: true };
|
|
594
|
+
}
|
|
349
595
|
const found = await execInTab(
|
|
350
596
|
id,
|
|
351
|
-
(
|
|
352
|
-
const el = document.querySelector(
|
|
597
|
+
(s, value, doClear) => {
|
|
598
|
+
const el = document.querySelector(s);
|
|
353
599
|
if (!el) return false;
|
|
354
600
|
el.focus();
|
|
355
|
-
|
|
356
|
-
|
|
601
|
+
const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), "value")?.set;
|
|
602
|
+
const next = (doClear ? "" : el.value ?? "") + value;
|
|
603
|
+
setter ? setter.call(el, next) : el.value = next;
|
|
357
604
|
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
358
605
|
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
359
606
|
return true;
|
|
360
607
|
},
|
|
361
|
-
[
|
|
608
|
+
[sel, text, clear]
|
|
362
609
|
);
|
|
363
|
-
if (!found) throw new CmdError("SELECTOR_NOT_FOUND", `no element for selector: ${
|
|
610
|
+
if (!found) throw new CmdError("SELECTOR_NOT_FOUND", `no element for selector: ${sel}`);
|
|
364
611
|
return { ok: true };
|
|
365
612
|
}
|
|
366
613
|
case "press": {
|
|
@@ -380,13 +627,16 @@
|
|
|
380
627
|
}
|
|
381
628
|
case "hover": {
|
|
382
629
|
const id = await targetTab(cmd);
|
|
630
|
+
const sel = requireSelector(cmd);
|
|
631
|
+
await waitForSelector(id, sel);
|
|
383
632
|
await execInTab(
|
|
384
633
|
id,
|
|
385
|
-
(
|
|
386
|
-
const el = document.querySelector(
|
|
634
|
+
(s) => {
|
|
635
|
+
const el = document.querySelector(s);
|
|
387
636
|
el?.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
|
|
637
|
+
el?.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
|
388
638
|
},
|
|
389
|
-
[
|
|
639
|
+
[sel]
|
|
390
640
|
);
|
|
391
641
|
return { ok: true };
|
|
392
642
|
}
|
|
@@ -402,17 +652,33 @@
|
|
|
402
652
|
);
|
|
403
653
|
return { ok: true };
|
|
404
654
|
}
|
|
405
|
-
// -- screenshot (visible tab) --
|
|
655
|
+
// -- screenshot (captureVisibleTab grabs the ACTIVE visible tab, so activate the target first) --
|
|
406
656
|
case "screenshot": {
|
|
407
657
|
const id = await targetTab(cmd);
|
|
408
|
-
|
|
658
|
+
let t = await chrome.tabs.get(id);
|
|
659
|
+
if (!t.active) {
|
|
660
|
+
await chrome.tabs.update(id, { active: true });
|
|
661
|
+
await chrome.windows.update(t.windowId, { focused: true }).catch(() => void 0);
|
|
662
|
+
await delay(150);
|
|
663
|
+
t = await chrome.tabs.get(id);
|
|
664
|
+
}
|
|
665
|
+
const dims = await execInTab(
|
|
666
|
+
id,
|
|
667
|
+
() => ({ w: window.innerWidth, h: window.innerHeight, full: document.documentElement.scrollHeight }),
|
|
668
|
+
[]
|
|
669
|
+
);
|
|
409
670
|
const dataUrl = await chrome.tabs.captureVisibleTab(t.windowId, { format: "png" });
|
|
671
|
+
const fullPage = cmd.params.fullPage === true;
|
|
672
|
+
const viewportH = dims?.h ?? 0;
|
|
673
|
+
const fullH = dims?.full ?? viewportH;
|
|
410
674
|
return {
|
|
411
675
|
dataBase64: dataUrl.split(",")[1] ?? "",
|
|
412
676
|
mimeType: "image/png",
|
|
413
|
-
width: 0,
|
|
414
|
-
height:
|
|
415
|
-
|
|
677
|
+
width: dims?.w ?? 0,
|
|
678
|
+
height: viewportH,
|
|
679
|
+
// The scripting backend can only capture the viewport; flag when a fullPage was asked but clipped.
|
|
680
|
+
truncated: fullPage && fullH > viewportH,
|
|
681
|
+
fullHeight: fullPage ? fullH : void 0
|
|
416
682
|
};
|
|
417
683
|
}
|
|
418
684
|
// -- eval (MAIN world; may be blocked by strict page CSP) --
|
|
@@ -472,11 +738,11 @@
|
|
|
472
738
|
}
|
|
473
739
|
};
|
|
474
740
|
function requireSelector(cmd) {
|
|
475
|
-
const
|
|
476
|
-
if (
|
|
477
|
-
throw new CmdError("BAD_ARGS",
|
|
741
|
+
const sel = resolveSelector(cmd);
|
|
742
|
+
if (!sel) {
|
|
743
|
+
throw new CmdError("BAD_ARGS", 'this command needs a "selector" or a "ref" from snapshot()');
|
|
478
744
|
}
|
|
479
|
-
return
|
|
745
|
+
return sel;
|
|
480
746
|
}
|
|
481
747
|
|
|
482
748
|
// extension/src/sw/router.ts
|
|
@@ -523,12 +789,28 @@
|
|
|
523
789
|
});
|
|
524
790
|
async function getConfig() {
|
|
525
791
|
const { wsPort, token } = await chrome.storage.local.get(["wsPort", "token"]);
|
|
526
|
-
if (typeof wsPort === "number" && typeof token === "string" && token.length > 0) {
|
|
792
|
+
if (typeof wsPort === "number" && wsPort > 0 && typeof token === "string" && token.length > 0) {
|
|
527
793
|
return { wsPort, token };
|
|
528
794
|
}
|
|
529
795
|
return null;
|
|
530
796
|
}
|
|
797
|
+
var BADGE = {
|
|
798
|
+
connected: { text: "\u25CF", color: "#16a34a", title: "Chrome MCP \u2014 connected" },
|
|
799
|
+
connecting: { text: "\u2026", color: "#ca8a04", title: "Chrome MCP \u2014 connecting" },
|
|
800
|
+
unauthorized: { text: "!", color: "#dc2626", title: "Chrome MCP \u2014 rejected (bad/stale token; re-pair)" },
|
|
801
|
+
idle: { text: "\u25CB", color: "#6b7280", title: "Chrome MCP \u2014 not connected (open options to pair)" }
|
|
802
|
+
};
|
|
803
|
+
function reflectBadge(state) {
|
|
804
|
+
const b = BADGE[state] ?? BADGE.idle;
|
|
805
|
+
try {
|
|
806
|
+
void chrome.action.setBadgeText({ text: b.text });
|
|
807
|
+
void chrome.action.setBadgeBackgroundColor({ color: b.color });
|
|
808
|
+
void chrome.action.setTitle({ title: b.title });
|
|
809
|
+
} catch {
|
|
810
|
+
}
|
|
811
|
+
}
|
|
531
812
|
async function persistState(state) {
|
|
813
|
+
reflectBadge(state);
|
|
532
814
|
await chrome.storage.local.set({ connState: state });
|
|
533
815
|
}
|
|
534
816
|
async function ensureConnected() {
|