@mehmoodqureshi/chrome-mcp 0.6.6 → 0.7.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 +116 -0
- package/dist/shared/observers.d.ts +110 -0
- package/dist/shared/observers.js +127 -0
- package/dist/shared/page-fns.d.ts +46 -0
- package/dist/shared/page-fns.js +292 -0
- package/dist/shared/policy.d.ts +9 -0
- package/dist/shared/policy.js +16 -0
- package/dist/shared/protocol.d.ts +11 -2
- package/dist/shared/protocol.js +3 -0
- package/dist/shared/snapshot.d.ts +2 -0
- package/dist/shared/snapshot.js +8 -1
- package/dist/src/bridge/workspace.d.ts +6 -0
- package/dist/src/bridge/workspace.js +20 -0
- package/dist/src/cli.js +13 -0
- package/dist/src/config.js +31 -0
- package/dist/src/executor/extension-executor.d.ts +27 -13
- package/dist/src/executor/extension-executor.js +53 -12
- package/dist/src/executor/stub-executor.d.ts +45 -1
- package/dist/src/executor/stub-executor.js +53 -8
- package/dist/src/executor/types.d.ts +72 -13
- package/dist/src/mcp/audit.d.ts +39 -0
- package/dist/src/mcp/audit.js +60 -0
- package/dist/src/mcp/batch.js +61 -9
- package/dist/src/mcp/helpers.d.ts +4 -4
- package/dist/src/mcp/helpers.js +9 -4
- package/dist/src/mcp/limits.d.ts +43 -0
- package/dist/src/mcp/limits.js +87 -0
- package/dist/src/mcp/locate.d.ts +45 -0
- package/dist/src/mcp/locate.js +105 -0
- package/dist/src/mcp/log.d.ts +17 -0
- package/dist/src/mcp/log.js +43 -0
- package/dist/src/mcp/redact.d.ts +48 -0
- package/dist/src/mcp/redact.js +92 -0
- package/dist/src/mcp/server.d.ts +1 -2
- package/dist/src/mcp/server.js +16 -12
- package/dist/src/mcp/snapdiff.d.ts +43 -0
- package/dist/src/mcp/snapdiff.js +92 -0
- package/dist/src/mcp/tools.js +481 -41
- package/dist/src/security/policy.d.ts +5 -0
- package/dist/src/security/policy.js +6 -0
- package/docs/BLUEPRINT.md +15 -1
- package/extension-dist/background.js +617 -214
- package/extension-dist/page-hook.js +215 -0
- package/package.json +1 -1
package/dist/src/mcp/batch.js
CHANGED
|
@@ -23,6 +23,17 @@ const validators_1 = require("./validators");
|
|
|
23
23
|
const MAX_OPS = 50;
|
|
24
24
|
const DEFAULT_CONCURRENCY = 6;
|
|
25
25
|
const MAX_CONCURRENCY = 16;
|
|
26
|
+
/**
|
|
27
|
+
* Default ceiling on the TOTAL payload a batch returns.
|
|
28
|
+
*
|
|
29
|
+
* Each op is individually bounded, but a batch multiplies: 50 screenshots or 50
|
|
30
|
+
* `get_html` reads compose into one unbounded result — which is exactly the case
|
|
31
|
+
* `batch` is most useful for. Past the budget, an op's blocks are replaced by a
|
|
32
|
+
* one-line summary so the caller still learns it ran and what it produced.
|
|
33
|
+
*/
|
|
34
|
+
const DEFAULT_MAX_RESULT_BYTES = 1024 * 1024;
|
|
35
|
+
const MIN_RESULT_BYTES = 4 * 1024;
|
|
36
|
+
const MAX_RESULT_BYTES = 32 * 1024 * 1024;
|
|
26
37
|
/** Validate the `ops` envelope. Structural problems throw (the whole batch is
|
|
27
38
|
* malformed); per-op semantic problems are handled later as per-op errors. */
|
|
28
39
|
function parseOps(raw) {
|
|
@@ -70,6 +81,7 @@ async function runBatch(rawArgs, deps) {
|
|
|
70
81
|
}
|
|
71
82
|
const stopOnError = (0, validators_1.optionalBoolean)(a, 'stopOnError') ?? false;
|
|
72
83
|
const concurrency = (0, validators_1.optionalNumber)(a, 'maxConcurrency', { min: 1, max: MAX_CONCURRENCY }) ?? DEFAULT_CONCURRENCY;
|
|
84
|
+
const maxResultBytes = (0, validators_1.optionalNumber)(a, 'maxResultBytes', { min: MIN_RESULT_BYTES, max: MAX_RESULT_BYTES }) ?? DEFAULT_MAX_RESULT_BYTES;
|
|
73
85
|
/** Run one op through the firewall, after the per-op guards. Never throws. */
|
|
74
86
|
const runOne = async (op) => {
|
|
75
87
|
if (op.tool === 'batch')
|
|
@@ -99,11 +111,26 @@ async function runBatch(rawArgs, deps) {
|
|
|
99
111
|
const results = await mapLimit(ops, concurrency, (op) => runOne(op));
|
|
100
112
|
outcomes = results.map((result) => ({ status: result.isError ? 'error' : 'ok', result }));
|
|
101
113
|
}
|
|
102
|
-
return renderBatch(ops, outcomes, mode);
|
|
114
|
+
return renderBatch(ops, outcomes, mode, maxResultBytes);
|
|
115
|
+
}
|
|
116
|
+
/** Approximate wire size of one content block (base64 image data dominates when present). */
|
|
117
|
+
function blockBytes(block) {
|
|
118
|
+
const b = block;
|
|
119
|
+
if (typeof b.text === 'string')
|
|
120
|
+
return Buffer.byteLength(b.text, 'utf8');
|
|
121
|
+
if (typeof b.data === 'string')
|
|
122
|
+
return b.data.length;
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
/** A one-line stand-in for an op whose blocks were dropped to stay inside the budget. */
|
|
126
|
+
function elidedSummary(index, tool, blocks, bytes) {
|
|
127
|
+
const kinds = [...new Set(blocks.map((b) => b.type))].join('+') || 'none';
|
|
128
|
+
return `--- op ${index} (${tool}) omitted: ${blocks.length} ${kinds} block(s), ~${bytes} bytes — batch result budget reached; re-run this op on its own to see it ---`;
|
|
103
129
|
}
|
|
104
130
|
/** Compose the per-op outcomes into one MCP result: a JSON summary block first,
|
|
105
|
-
* then each executed op's own content blocks (text/images flow through intact)
|
|
106
|
-
|
|
131
|
+
* then each executed op's own content blocks (text/images flow through intact),
|
|
132
|
+
* stopping at `budget` bytes so one batch cannot flood the caller's context. */
|
|
133
|
+
function renderBatch(ops, outcomes, mode, budget) {
|
|
107
134
|
const summary = outcomes.map((o, i) => ({ index: i, tool: ops[i].tool, status: o.status }));
|
|
108
135
|
const counts = {
|
|
109
136
|
total: ops.length,
|
|
@@ -111,17 +138,42 @@ function renderBatch(ops, outcomes, mode) {
|
|
|
111
138
|
error: summary.filter((s) => s.status === 'error').length,
|
|
112
139
|
skipped: summary.filter((s) => s.status === 'skipped').length,
|
|
113
140
|
};
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
];
|
|
141
|
+
// Render the payload first so the header can report how much was elided — the
|
|
142
|
+
// caller needs that number to decide whether to re-run anything.
|
|
143
|
+
const body = [];
|
|
144
|
+
let spent = 0;
|
|
145
|
+
let omittedOps = 0;
|
|
146
|
+
let omittedBytes = 0;
|
|
117
147
|
for (let i = 0; i < outcomes.length; i++) {
|
|
118
148
|
const o = outcomes[i];
|
|
119
149
|
if (!o.result)
|
|
120
150
|
continue; // skipped ops carry no payload
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
151
|
+
const blocks = o.result.content;
|
|
152
|
+
const size = blocks.reduce((n, b) => n + blockBytes(b), 0);
|
|
153
|
+
if (spent + size > budget && spent > 0) {
|
|
154
|
+
// `spent > 0` guarantees the first op always gets through: a single op
|
|
155
|
+
// larger than the whole budget is still more useful than an empty batch.
|
|
156
|
+
body.push({ type: 'text', text: elidedSummary(i, ops[i].tool, blocks, size) });
|
|
157
|
+
omittedOps++;
|
|
158
|
+
omittedBytes += size;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
body.push({ type: 'text', text: `--- op ${i} (${ops[i].tool}) ${o.status} ---` });
|
|
162
|
+
for (const block of blocks)
|
|
163
|
+
body.push(block);
|
|
164
|
+
spent += size;
|
|
124
165
|
}
|
|
166
|
+
const header = {
|
|
167
|
+
batch: {
|
|
168
|
+
mode,
|
|
169
|
+
...counts,
|
|
170
|
+
...(omittedOps > 0
|
|
171
|
+
? { omittedOps, omittedBytes, resultBudgetBytes: budget, note: 'some op payloads were omitted to stay within the batch result budget; raise maxResultBytes or re-run those ops individually' }
|
|
172
|
+
: {}),
|
|
173
|
+
},
|
|
174
|
+
results: summary,
|
|
175
|
+
};
|
|
176
|
+
const content = [{ type: 'text', text: JSON.stringify(header, null, 2) }, ...body];
|
|
125
177
|
// The batch ran successfully even if some ops failed; only flag isError when
|
|
126
178
|
// nothing succeeded, so a host sees partial success as success.
|
|
127
179
|
const isError = ops.length > 0 && counts.ok === 0;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* `read_as_markdown` read via primitives; `fill_form` sequences fill+click.
|
|
5
5
|
* (Only `download_file` is privileged and lives on the executor.)
|
|
6
6
|
*/
|
|
7
|
-
import type { Executor } from '../executor/types';
|
|
7
|
+
import type { Executor, FrameOpts } from '../executor/types';
|
|
8
8
|
export interface LinkOut {
|
|
9
9
|
href: string;
|
|
10
10
|
text: string;
|
|
@@ -24,20 +24,20 @@ export declare function extractLinks(ex: Executor, args: {
|
|
|
24
24
|
dedupe?: boolean;
|
|
25
25
|
limit?: number;
|
|
26
26
|
tabId?: string;
|
|
27
|
-
}): Promise<{
|
|
27
|
+
} & FrameOpts): Promise<{
|
|
28
28
|
links: LinkOut[];
|
|
29
29
|
}>;
|
|
30
30
|
/** Read a page (or subtree) as readable markdown. */
|
|
31
31
|
export declare function readAsMarkdown(ex: Executor, args: {
|
|
32
32
|
selector?: string;
|
|
33
33
|
tabId?: string;
|
|
34
|
-
}): Promise<string>;
|
|
34
|
+
} & FrameOpts): Promise<string>;
|
|
35
35
|
/** Fill a set of fields (keyed by selector) and optionally submit. */
|
|
36
36
|
export declare function fillForm(ex: Executor, args: {
|
|
37
37
|
fields: Record<string, string | boolean>;
|
|
38
38
|
submitSelector?: string;
|
|
39
39
|
tabId?: string;
|
|
40
|
-
}): Promise<{
|
|
40
|
+
} & FrameOpts): Promise<{
|
|
41
41
|
filled: number;
|
|
42
42
|
submitted: boolean;
|
|
43
43
|
}>;
|
package/dist/src/mcp/helpers.js
CHANGED
|
@@ -28,8 +28,9 @@ async function extractLinks(ex, args) {
|
|
|
28
28
|
href: a.href, text: (a.textContent || '').trim().slice(0, 200),
|
|
29
29
|
})).filter(l => l.href && (${args.sameOriginOnly ? 'l.href.startsWith(here)' : 'true'}));
|
|
30
30
|
})()`;
|
|
31
|
+
const frames = { frameId: args.frameId, allFrames: args.allFrames };
|
|
31
32
|
let links;
|
|
32
|
-
const res = await ex.eval(expr, { tabId: args.tabId });
|
|
33
|
+
const res = await ex.eval(expr, { tabId: args.tabId, ...frames });
|
|
33
34
|
if (res.ok && Array.isArray(res.value)) {
|
|
34
35
|
links = res.value;
|
|
35
36
|
}
|
|
@@ -37,6 +38,7 @@ async function extractLinks(ex, args) {
|
|
|
37
38
|
// Fallback: parse hrefs out of the HTML (e.g. when eval is policy-denied).
|
|
38
39
|
const { html } = await ex.getHtml(args.selector ? { selector: args.selector } : undefined, {
|
|
39
40
|
tabId: args.tabId,
|
|
41
|
+
...frames,
|
|
40
42
|
});
|
|
41
43
|
links = [];
|
|
42
44
|
const re = /<a[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi;
|
|
@@ -73,26 +75,29 @@ function refineLinks(links, opts) {
|
|
|
73
75
|
async function readAsMarkdown(ex, args) {
|
|
74
76
|
const { html } = await ex.getHtml(args.selector ? { selector: args.selector } : undefined, {
|
|
75
77
|
tabId: args.tabId,
|
|
78
|
+
frameId: args.frameId,
|
|
79
|
+
allFrames: args.allFrames,
|
|
76
80
|
});
|
|
77
81
|
return (0, markdown_extract_1.htmlToMarkdown)(html);
|
|
78
82
|
}
|
|
79
83
|
/** Fill a set of fields (keyed by selector) and optionally submit. */
|
|
80
84
|
async function fillForm(ex, args) {
|
|
85
|
+
const opts = { tabId: args.tabId, frameId: args.frameId, allFrames: args.allFrames };
|
|
81
86
|
let filled = 0;
|
|
82
87
|
for (const [selector, value] of Object.entries(args.fields)) {
|
|
83
88
|
const target = { selector };
|
|
84
89
|
if (typeof value === 'boolean') {
|
|
85
90
|
// Checkbox/radio: a click toggles it.
|
|
86
|
-
await ex.click(target,
|
|
91
|
+
await ex.click(target, opts);
|
|
87
92
|
}
|
|
88
93
|
else {
|
|
89
|
-
await ex.fill(target, value,
|
|
94
|
+
await ex.fill(target, value, opts);
|
|
90
95
|
}
|
|
91
96
|
filled++;
|
|
92
97
|
}
|
|
93
98
|
let submitted = false;
|
|
94
99
|
if (args.submitSelector) {
|
|
95
|
-
await ex.click({ selector: args.submitSelector },
|
|
100
|
+
await ex.click({ selector: args.submitSelector }, opts);
|
|
96
101
|
submitted = true;
|
|
97
102
|
}
|
|
98
103
|
return { filled, submitted };
|
|
@@ -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,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/mcp/locate.ts — target an element by what it IS rather than by where it
|
|
3
|
+
* sits in the DOM.
|
|
4
|
+
*
|
|
5
|
+
* Today every action needs a CSS selector or a `ref` from a snapshot, so the
|
|
6
|
+
* cheapest way to click "Sign in" is to pull the whole accessibility tree first
|
|
7
|
+
* and read a ref out of it. That is a large read to perform one small action,
|
|
8
|
+
* and a hand-written selector is the alternative that breaks on the next
|
|
9
|
+
* redeploy.
|
|
10
|
+
*
|
|
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.
|
|
16
|
+
*/
|
|
17
|
+
import type { Executor, SnapshotNode, Target } from '../executor/types';
|
|
18
|
+
export interface Locator {
|
|
19
|
+
role?: string;
|
|
20
|
+
name?: string;
|
|
21
|
+
/** Alias for `name`, for callers that think in visible text. */
|
|
22
|
+
text?: string;
|
|
23
|
+
/** Pick the nth match (0-based) when a locator is legitimately ambiguous. */
|
|
24
|
+
nth?: number;
|
|
25
|
+
}
|
|
26
|
+
/** Was a locator supplied at all? */
|
|
27
|
+
export declare function hasLocator(l: Locator | undefined): boolean;
|
|
28
|
+
export interface Resolution {
|
|
29
|
+
target: Target;
|
|
30
|
+
node: SnapshotNode;
|
|
31
|
+
/** How many nodes matched at the same (winning) strength. */
|
|
32
|
+
matches: number;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Resolve a locator to a `ref` by taking one snapshot of the target tab.
|
|
36
|
+
*
|
|
37
|
+
* Throws `McpToolError` when nothing matches or when the best tier is ambiguous
|
|
38
|
+
* — an ambiguous click is a wrong click, and the message lists what it found so
|
|
39
|
+
* the caller can narrow it (or pass `nth`).
|
|
40
|
+
*/
|
|
41
|
+
export declare function resolveLocator(ex: Executor, loc: Locator, opts?: {
|
|
42
|
+
tabId?: string;
|
|
43
|
+
frameId?: number;
|
|
44
|
+
allFrames?: boolean;
|
|
45
|
+
}): Promise<Resolution>;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* src/mcp/locate.ts — target an element by what it IS rather than by where it
|
|
4
|
+
* sits in the DOM.
|
|
5
|
+
*
|
|
6
|
+
* Today every action needs a CSS selector or a `ref` from a snapshot, so the
|
|
7
|
+
* cheapest way to click "Sign in" is to pull the whole accessibility tree first
|
|
8
|
+
* and read a ref out of it. That is a large read to perform one small action,
|
|
9
|
+
* and a hand-written selector is the alternative that breaks on the next
|
|
10
|
+
* redeploy.
|
|
11
|
+
*
|
|
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.
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.hasLocator = hasLocator;
|
|
20
|
+
exports.resolveLocator = resolveLocator;
|
|
21
|
+
const validators_1 = require("./validators");
|
|
22
|
+
/** Was a locator supplied at all? */
|
|
23
|
+
function hasLocator(l) {
|
|
24
|
+
return !!l && (l.role !== undefined || l.name !== undefined || l.text !== undefined);
|
|
25
|
+
}
|
|
26
|
+
const norm = (s) => s.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
27
|
+
/**
|
|
28
|
+
* Rank a node against the locator. Higher is better; 0 means no match.
|
|
29
|
+
* The tiers are what make an exact name beat a substring of a longer label.
|
|
30
|
+
*/
|
|
31
|
+
function score(node, want) {
|
|
32
|
+
if (want.role && norm(node.role) !== norm(want.role))
|
|
33
|
+
return 0;
|
|
34
|
+
if (!want.name)
|
|
35
|
+
return 1; // role-only locator: any node of that role
|
|
36
|
+
const have = norm(node.name);
|
|
37
|
+
const need = norm(want.name);
|
|
38
|
+
if (!have)
|
|
39
|
+
return 0;
|
|
40
|
+
if (node.name.trim() === want.name.trim())
|
|
41
|
+
return 4; // exact, case-sensitive
|
|
42
|
+
if (have === need)
|
|
43
|
+
return 3; // exact, case-insensitive
|
|
44
|
+
if (have.startsWith(need))
|
|
45
|
+
return 2;
|
|
46
|
+
if (have.includes(need))
|
|
47
|
+
return 1;
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Resolve a locator to a `ref` by taking one snapshot of the target tab.
|
|
52
|
+
*
|
|
53
|
+
* Throws `McpToolError` when nothing matches or when the best tier is ambiguous
|
|
54
|
+
* — an ambiguous click is a wrong click, and the message lists what it found so
|
|
55
|
+
* the caller can narrow it (or pass `nth`).
|
|
56
|
+
*/
|
|
57
|
+
async function resolveLocator(ex, loc, opts = {}) {
|
|
58
|
+
const want = { role: loc.role, name: loc.name ?? loc.text };
|
|
59
|
+
const snap = await ex.snapshot({
|
|
60
|
+
tabId: opts.tabId,
|
|
61
|
+
interactiveOnly: false,
|
|
62
|
+
max: 400,
|
|
63
|
+
frameId: opts.frameId,
|
|
64
|
+
allFrames: opts.allFrames,
|
|
65
|
+
});
|
|
66
|
+
let best = 0;
|
|
67
|
+
let winners = [];
|
|
68
|
+
for (const node of snap.nodes) {
|
|
69
|
+
const s = score(node, want);
|
|
70
|
+
if (s === 0)
|
|
71
|
+
continue;
|
|
72
|
+
if (s > best) {
|
|
73
|
+
best = s;
|
|
74
|
+
winners = [node];
|
|
75
|
+
}
|
|
76
|
+
else if (s === best) {
|
|
77
|
+
winners.push(node);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const describe = (n) => `${n.role} "${n.name}"`;
|
|
81
|
+
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
|
+
throw new validators_1.McpToolError(`no element matches ${JSON.stringify(want)}. ` +
|
|
87
|
+
(sample.length
|
|
88
|
+
? `Closest by role: ${sample.join(', ')}. `
|
|
89
|
+
: 'Nothing on the page has that role. ') +
|
|
90
|
+
'Take a `snapshot` to see what is there, or target by `selector` instead.');
|
|
91
|
+
}
|
|
92
|
+
if (loc.nth !== undefined) {
|
|
93
|
+
const picked = winners[loc.nth];
|
|
94
|
+
if (!picked) {
|
|
95
|
+
throw new validators_1.McpToolError(`nth=${loc.nth} is out of range: ${winners.length} element(s) match ${JSON.stringify(want)}`);
|
|
96
|
+
}
|
|
97
|
+
return { target: { ref: picked.ref }, node: picked, matches: winners.length };
|
|
98
|
+
}
|
|
99
|
+
if (winners.length > 1) {
|
|
100
|
+
throw new validators_1.McpToolError(`${winners.length} elements match ${JSON.stringify(want)}: ${winners.slice(0, 6).map(describe).join(', ')}. ` +
|
|
101
|
+
'Narrow the name, add a role, or pass `nth` to choose one.');
|
|
102
|
+
}
|
|
103
|
+
return { target: { ref: winners[0].ref }, node: winners[0], matches: 1 };
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=locate.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
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/mcp/redact.ts — keep secrets that happen to be on the page out of the
|
|
3
|
+
* model's context.
|
|
4
|
+
*
|
|
5
|
+
* The premise of this tool is that it drives a browser you are already logged
|
|
6
|
+
* into. That is also the problem: `get_text` / `get_html` / `read_as_markdown` /
|
|
7
|
+
* `eval` return whatever is on the page, and on a logged-in page that routinely
|
|
8
|
+
* includes a session token rendered into a script tag, an API key on a settings
|
|
9
|
+
* screen, or the value sitting in a password field. The domain allowlist decides
|
|
10
|
+
* WHICH pages may be read; it has nothing to say about what comes back from one
|
|
11
|
+
* that is allowed.
|
|
12
|
+
*
|
|
13
|
+
* Two layers, deliberately different in strength:
|
|
14
|
+
* - password-field values are ALWAYS suppressed. That one is unambiguous — no
|
|
15
|
+
* caller ever wants the characters in a `<input type=password>` — so it
|
|
16
|
+
* needs no flag and has no false positives.
|
|
17
|
+
* - pattern redaction (JWTs, cloud keys, bearer tokens, private key blocks) is
|
|
18
|
+
* opt-in via `--redact`, because a pattern can and will fire on something a
|
|
19
|
+
* user legitimately asked to read.
|
|
20
|
+
*/
|
|
21
|
+
export interface RedactionConfig {
|
|
22
|
+
/** Pattern-based redaction (the opt-in layer). Password fields are handled regardless. */
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
/** Extra caller-supplied patterns, already compiled. */
|
|
25
|
+
extra: RegExp[];
|
|
26
|
+
}
|
|
27
|
+
export declare const NO_REDACTION: RedactionConfig;
|
|
28
|
+
/**
|
|
29
|
+
* Compile a user-supplied pattern. Invalid regexes are a configuration error
|
|
30
|
+
* worth failing loudly on — silently ignoring one would leave the user believing
|
|
31
|
+
* a secret is being scrubbed when it is not.
|
|
32
|
+
*/
|
|
33
|
+
export declare function compileRedactionPattern(source: string): RegExp;
|
|
34
|
+
export interface Redacted<T> {
|
|
35
|
+
value: T;
|
|
36
|
+
/** How many substitutions were made, so a caller can see redaction happened. */
|
|
37
|
+
redactions: number;
|
|
38
|
+
}
|
|
39
|
+
/** Replace every match of the configured patterns with a labelled marker. */
|
|
40
|
+
export declare function redactText(text: string, cfg: RedactionConfig): Redacted<string>;
|
|
41
|
+
/**
|
|
42
|
+
* Strip the `value` of every password input, then apply pattern redaction.
|
|
43
|
+
*
|
|
44
|
+
* The value attribute is emptied rather than removed so the markup keeps its
|
|
45
|
+
* shape — a caller reasoning about the form still sees the field, just not
|
|
46
|
+
* what is in it.
|
|
47
|
+
*/
|
|
48
|
+
export declare function redactHtml(html: string, cfg: RedactionConfig): Redacted<string>;
|