@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
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* src/mcp/redact.ts — keep secrets that happen to be on the page out of the
|
|
4
|
+
* model's context.
|
|
5
|
+
*
|
|
6
|
+
* The premise of this tool is that it drives a browser you are already logged
|
|
7
|
+
* into. That is also the problem: `get_text` / `get_html` / `read_as_markdown` /
|
|
8
|
+
* `eval` return whatever is on the page, and on a logged-in page that routinely
|
|
9
|
+
* includes a session token rendered into a script tag, an API key on a settings
|
|
10
|
+
* screen, or the value sitting in a password field. The domain allowlist decides
|
|
11
|
+
* WHICH pages may be read; it has nothing to say about what comes back from one
|
|
12
|
+
* that is allowed.
|
|
13
|
+
*
|
|
14
|
+
* Two layers, deliberately different in strength:
|
|
15
|
+
* - password-field values are ALWAYS suppressed. That one is unambiguous — no
|
|
16
|
+
* caller ever wants the characters in a `<input type=password>` — so it
|
|
17
|
+
* needs no flag and has no false positives.
|
|
18
|
+
* - pattern redaction (JWTs, cloud keys, bearer tokens, private key blocks) is
|
|
19
|
+
* opt-in via `--redact`, because a pattern can and will fire on something a
|
|
20
|
+
* user legitimately asked to read.
|
|
21
|
+
*/
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.NO_REDACTION = void 0;
|
|
24
|
+
exports.compileRedactionPattern = compileRedactionPattern;
|
|
25
|
+
exports.redactText = redactText;
|
|
26
|
+
exports.redactHtml = redactHtml;
|
|
27
|
+
exports.NO_REDACTION = { enabled: false, extra: [] };
|
|
28
|
+
/** Well-known secret shapes. Each is anchored enough not to fire on prose. */
|
|
29
|
+
const BUILTIN = [
|
|
30
|
+
{ kind: 'private-key', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]{0,8000}?-----END [A-Z ]*PRIVATE KEY-----/g },
|
|
31
|
+
{ kind: 'jwt', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
|
|
32
|
+
{ kind: 'aws-key', re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
|
|
33
|
+
{ kind: 'github-token', re: /\bgh[pousr]_[A-Za-z0-9]{30,}\b/g },
|
|
34
|
+
{ kind: 'slack-token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
|
|
35
|
+
{ kind: 'google-api-key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
|
|
36
|
+
{ kind: 'api-key', re: /\bsk-[A-Za-z0-9_-]{20,}\b/g },
|
|
37
|
+
{ kind: 'bearer', re: /\bBearer\s+[A-Za-z0-9._~+/-]{20,}={0,2}/g },
|
|
38
|
+
];
|
|
39
|
+
/**
|
|
40
|
+
* Compile a user-supplied pattern. Invalid regexes are a configuration error
|
|
41
|
+
* worth failing loudly on — silently ignoring one would leave the user believing
|
|
42
|
+
* a secret is being scrubbed when it is not.
|
|
43
|
+
*/
|
|
44
|
+
function compileRedactionPattern(source) {
|
|
45
|
+
return new RegExp(source, 'g');
|
|
46
|
+
}
|
|
47
|
+
/** Replace every match of the configured patterns with a labelled marker. */
|
|
48
|
+
function redactText(text, cfg) {
|
|
49
|
+
if (!cfg.enabled || !text)
|
|
50
|
+
return { value: text, redactions: 0 };
|
|
51
|
+
let out = text;
|
|
52
|
+
let count = 0;
|
|
53
|
+
for (const { kind, re } of BUILTIN) {
|
|
54
|
+
out = out.replace(new RegExp(re.source, re.flags), () => {
|
|
55
|
+
count++;
|
|
56
|
+
return `[redacted:${kind}]`;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
for (const re of cfg.extra) {
|
|
60
|
+
out = out.replace(new RegExp(re.source, re.flags.includes('g') ? re.flags : `${re.flags}g`), () => {
|
|
61
|
+
count++;
|
|
62
|
+
return '[redacted:custom]';
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return { value: out, redactions: count };
|
|
66
|
+
}
|
|
67
|
+
/** Matches one complete `<input …>` tag so its attributes can be inspected. */
|
|
68
|
+
const INPUT_TAG = /<input\b[^>]*>/gi;
|
|
69
|
+
const VALUE_ATTR = /\bvalue\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/i;
|
|
70
|
+
const PASSWORD_TYPE = /\btype\s*=\s*["']?password["']?/i;
|
|
71
|
+
/**
|
|
72
|
+
* Strip the `value` of every password input, then apply pattern redaction.
|
|
73
|
+
*
|
|
74
|
+
* The value attribute is emptied rather than removed so the markup keeps its
|
|
75
|
+
* shape — a caller reasoning about the form still sees the field, just not
|
|
76
|
+
* what is in it.
|
|
77
|
+
*/
|
|
78
|
+
function redactHtml(html, cfg) {
|
|
79
|
+
if (!html)
|
|
80
|
+
return { value: html, redactions: 0 };
|
|
81
|
+
let count = 0;
|
|
82
|
+
let out = html.replace(INPUT_TAG, (tag) => {
|
|
83
|
+
if (!PASSWORD_TYPE.test(tag) || !VALUE_ATTR.test(tag))
|
|
84
|
+
return tag;
|
|
85
|
+
count++;
|
|
86
|
+
return tag.replace(VALUE_ATTR, 'value="[redacted:password]"');
|
|
87
|
+
});
|
|
88
|
+
const patterned = redactText(out, cfg);
|
|
89
|
+
out = patterned.value;
|
|
90
|
+
return { value: out, redactions: count + patterned.redactions };
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=redact.js.map
|
package/dist/src/mcp/server.d.ts
CHANGED
|
@@ -7,8 +7,7 @@
|
|
|
7
7
|
* go to stderr via `logErr`.
|
|
8
8
|
*/
|
|
9
9
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
10
|
-
|
|
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. */
|
package/dist/src/mcp/server.js
CHANGED
|
@@ -8,38 +8,42 @@
|
|
|
8
8
|
* go to stderr via `logErr`.
|
|
9
9
|
*/
|
|
10
10
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
-
exports.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;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/mcp/snapdiff.ts — remember the last accessibility snapshot per tab and
|
|
3
|
+
* answer "what changed" instead of re-sending the page.
|
|
4
|
+
*
|
|
5
|
+
* A snapshot is the most token-expensive read in the tool surface, and the agent
|
|
6
|
+
* loop that leans on it hardest — snapshot, click, snapshot, click — re-sends a
|
|
7
|
+
* page that is mostly identical every time. The diff is what the caller actually
|
|
8
|
+
* wanted: this button appeared, that error text is new, submit is no longer
|
|
9
|
+
* disabled.
|
|
10
|
+
*
|
|
11
|
+
* Refs (`e1`, `e2`, ...) are minted in document order on every snapshot, so they
|
|
12
|
+
* are NOT identity across snapshots: `e7` is a different element the moment
|
|
13
|
+
* anything above it is inserted. Nodes are therefore matched on role + tag +
|
|
14
|
+
* accessible name, and every diff entry carries the CURRENT ref, so anything the
|
|
15
|
+
* caller is told about is immediately targetable.
|
|
16
|
+
*/
|
|
17
|
+
import type { SnapshotNode, SnapshotResult } from '../executor/types';
|
|
18
|
+
export interface StoredSnapshot {
|
|
19
|
+
id: string;
|
|
20
|
+
ts: number;
|
|
21
|
+
url: string;
|
|
22
|
+
nodes: SnapshotNode[];
|
|
23
|
+
}
|
|
24
|
+
export interface SnapshotDiff {
|
|
25
|
+
/** The snapshot this was compared against, or null when there was none. */
|
|
26
|
+
since: string | null;
|
|
27
|
+
added: SnapshotNode[];
|
|
28
|
+
removed: SnapshotNode[];
|
|
29
|
+
changed: Array<{
|
|
30
|
+
node: SnapshotNode;
|
|
31
|
+
was: Partial<SnapshotNode>;
|
|
32
|
+
}>;
|
|
33
|
+
unchanged: number;
|
|
34
|
+
}
|
|
35
|
+
/** Identity of a node ACROSS snapshots - deliberately not the ref. */
|
|
36
|
+
export declare function nodeKey(n: SnapshotNode): string;
|
|
37
|
+
/** Scope key: snapshots are per browser profile and per tab. */
|
|
38
|
+
export declare function scopeOf(profile: string, tabId?: string): string;
|
|
39
|
+
export declare function rememberSnapshot(scope: string, snap: SnapshotResult): StoredSnapshot;
|
|
40
|
+
export declare function lastSnapshot(scope: string): StoredSnapshot | undefined;
|
|
41
|
+
/** Forget everything - for tests, and whenever the active profile changes. */
|
|
42
|
+
export declare function resetSnapshots(): void;
|
|
43
|
+
export declare function diffSnapshots(prev: StoredSnapshot | undefined, next: SnapshotResult): SnapshotDiff;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* src/mcp/snapdiff.ts — remember the last accessibility snapshot per tab and
|
|
4
|
+
* answer "what changed" instead of re-sending the page.
|
|
5
|
+
*
|
|
6
|
+
* A snapshot is the most token-expensive read in the tool surface, and the agent
|
|
7
|
+
* loop that leans on it hardest — snapshot, click, snapshot, click — re-sends a
|
|
8
|
+
* page that is mostly identical every time. The diff is what the caller actually
|
|
9
|
+
* wanted: this button appeared, that error text is new, submit is no longer
|
|
10
|
+
* disabled.
|
|
11
|
+
*
|
|
12
|
+
* Refs (`e1`, `e2`, ...) are minted in document order on every snapshot, so they
|
|
13
|
+
* are NOT identity across snapshots: `e7` is a different element the moment
|
|
14
|
+
* anything above it is inserted. Nodes are therefore matched on role + tag +
|
|
15
|
+
* accessible name, and every diff entry carries the CURRENT ref, so anything the
|
|
16
|
+
* caller is told about is immediately targetable.
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.nodeKey = nodeKey;
|
|
20
|
+
exports.scopeOf = scopeOf;
|
|
21
|
+
exports.rememberSnapshot = rememberSnapshot;
|
|
22
|
+
exports.lastSnapshot = lastSnapshot;
|
|
23
|
+
exports.resetSnapshots = resetSnapshots;
|
|
24
|
+
exports.diffSnapshots = diffSnapshots;
|
|
25
|
+
/** How many tabs' snapshots to keep. Small: a convenience cache, not state. */
|
|
26
|
+
const MAX_SCOPES = 32;
|
|
27
|
+
const store = new Map();
|
|
28
|
+
let counter = 0;
|
|
29
|
+
/** Identity of a node ACROSS snapshots - deliberately not the ref. */
|
|
30
|
+
function nodeKey(n) {
|
|
31
|
+
return `${n.role} | ${n.tag} | ${n.name}`;
|
|
32
|
+
}
|
|
33
|
+
/** Scope key: snapshots are per browser profile and per tab. */
|
|
34
|
+
function scopeOf(profile, tabId) {
|
|
35
|
+
return `${profile} | ${tabId ?? 'active'}`;
|
|
36
|
+
}
|
|
37
|
+
function rememberSnapshot(scope, snap) {
|
|
38
|
+
const stored = { id: `snap_${++counter}`, ts: Date.now(), url: snap.url, nodes: snap.nodes };
|
|
39
|
+
store.set(scope, stored);
|
|
40
|
+
// Bounded: drop the oldest insertion once over the ceiling.
|
|
41
|
+
while (store.size > MAX_SCOPES) {
|
|
42
|
+
const oldest = store.keys().next();
|
|
43
|
+
if (oldest.done)
|
|
44
|
+
break;
|
|
45
|
+
store.delete(oldest.value);
|
|
46
|
+
}
|
|
47
|
+
return stored;
|
|
48
|
+
}
|
|
49
|
+
function lastSnapshot(scope) {
|
|
50
|
+
return store.get(scope);
|
|
51
|
+
}
|
|
52
|
+
/** Forget everything - for tests, and whenever the active profile changes. */
|
|
53
|
+
function resetSnapshots() {
|
|
54
|
+
store.clear();
|
|
55
|
+
counter = 0;
|
|
56
|
+
}
|
|
57
|
+
/** The state fields a diff reports as "changed" (the ref is expected to move). */
|
|
58
|
+
const STATE_FIELDS = ['value', 'disabled', 'checked'];
|
|
59
|
+
function diffSnapshots(prev, next) {
|
|
60
|
+
if (!prev) {
|
|
61
|
+
return { since: null, added: next.nodes, removed: [], changed: [], unchanged: 0 };
|
|
62
|
+
}
|
|
63
|
+
const before = new Map();
|
|
64
|
+
for (const n of prev.nodes)
|
|
65
|
+
if (!before.has(nodeKey(n)))
|
|
66
|
+
before.set(nodeKey(n), n);
|
|
67
|
+
const added = [];
|
|
68
|
+
const changed = [];
|
|
69
|
+
let unchanged = 0;
|
|
70
|
+
const matched = new Set();
|
|
71
|
+
for (const n of next.nodes) {
|
|
72
|
+
const key = nodeKey(n);
|
|
73
|
+
const old = before.get(key);
|
|
74
|
+
if (!old) {
|
|
75
|
+
added.push(n);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
matched.add(key);
|
|
79
|
+
const was = {};
|
|
80
|
+
for (const f of STATE_FIELDS) {
|
|
81
|
+
if (old[f] !== n[f])
|
|
82
|
+
was[f] = old[f];
|
|
83
|
+
}
|
|
84
|
+
if (Object.keys(was).length > 0)
|
|
85
|
+
changed.push({ node: n, was });
|
|
86
|
+
else
|
|
87
|
+
unchanged++;
|
|
88
|
+
}
|
|
89
|
+
const removed = prev.nodes.filter((n) => !matched.has(nodeKey(n)));
|
|
90
|
+
return { since: prev.id, added, removed, changed, unchanged };
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=snapdiff.js.map
|