mcp-browser-extension 0.9.28 → 0.9.30
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 +20 -2
- package/dist/shared/page-fns.d.ts +7 -1
- package/dist/shared/page-fns.js +37 -2
- package/dist/shared/policy.js +10 -1
- package/dist/shared/snapshot.js +15 -4
- package/dist/src/bridge/host-claim.d.ts +17 -0
- package/dist/src/bridge/host-claim.js +42 -0
- package/dist/src/cli.js +17 -5
- package/dist/src/executor/cdp-executor.d.ts +3 -0
- package/dist/src/executor/cdp-executor.js +14 -0
- package/dist/src/executor/extension-executor.js +14 -4
- package/dist/src/executor/types.d.ts +4 -0
- package/dist/src/mcp/helpers.js +11 -2
- package/dist/src/mcp/page-watch.d.ts +12 -0
- package/dist/src/mcp/page-watch.js +79 -0
- package/dist/src/mcp/tools.js +73 -9
- package/dist/src/security/policy.d.ts +7 -0
- package/dist/src/security/policy.js +11 -0
- package/extension-dist/background.js +238 -64
- package/extension-dist/manifest.json +3 -3
- package/extension-dist/page-hook.js +6 -0
- package/package.json +2 -1
package/dist/src/mcp/tools.js
CHANGED
|
@@ -38,6 +38,7 @@ const helpers_1 = require("./helpers");
|
|
|
38
38
|
const redact_1 = require("./redact");
|
|
39
39
|
const locate_1 = require("./locate");
|
|
40
40
|
const snapdiff_1 = require("./snapdiff");
|
|
41
|
+
const page_watch_1 = require("./page-watch");
|
|
41
42
|
const auth_wall_1 = require("../../shared/auth-wall");
|
|
42
43
|
const audit_1 = require("./audit");
|
|
43
44
|
const log_1 = require("./log");
|
|
@@ -104,7 +105,7 @@ const maxBytesField = zod_1.z
|
|
|
104
105
|
const waitUntilField = zod_1.z.enum(['load', 'domcontentloaded', 'networkidle']).describe('When to consider navigation done').optional();
|
|
105
106
|
exports.TOOL_DEFINITIONS = [
|
|
106
107
|
{ name: 'tabs_list', description: 'List open browser tabs.', inputSchema: {} },
|
|
107
|
-
{ name: 'tab_select', description:
|
|
108
|
+
{ name: 'tab_select', description: "Make a tab the agent's tab by tabId, in the background: calls without tabId go to it and the user stays on their own tab. Pass show:true only to bring it to the front for the user.", inputSchema: { tabId: zod_1.z.string(), show: zod_1.z.boolean().optional() } },
|
|
108
109
|
{ name: 'tab_new', description: "Open a NEW tab (optionally at a URL) in the background, leaving the user's tab alone; it becomes the agent's tab, where calls without tabId go. Pass active:true to show it to the user.", inputSchema: { url: zod_1.z.string().optional(), active: zod_1.z.boolean().optional() } },
|
|
109
110
|
{ name: 'tab_close', description: 'Close a tab by tabId.', inputSchema: { tabId: zod_1.z.string() } },
|
|
110
111
|
{ name: 'navigate', description: "Navigate the agent's tab to a URL, replacing its page. With no agent's tab yet it opens one in the background: the user's own tab is only navigated when you pass its tabId.", inputSchema: { url: zod_1.z.string(), tabId: tabIdField, waitUntil: waitUntilField, failOnAuthWall: authWallField } },
|
|
@@ -122,6 +123,7 @@ exports.TOOL_DEFINITIONS = [
|
|
|
122
123
|
{ name: 'get_text', description: 'Get visible text of the page or an element.', inputSchema: { ...TARGET_PROPS, ...FRAME_PROPS, tabId: tabIdField, maxBytes: maxBytesField } },
|
|
123
124
|
{ name: 'get_html', description: 'Get HTML of the page or an element. Output is capped (see maxBytes) and cut at a tag boundary; narrow it with `selector` rather than raising the cap when you can. Password field values are always blanked.', inputSchema: { ...TARGET_PROPS, ...FRAME_PROPS, outer: zod_1.z.boolean().optional(), tabId: tabIdField, maxBytes: maxBytesField } },
|
|
124
125
|
{ name: 'snapshot', description: 'Accessibility snapshot: interactive elements with refs to target by `ref` (more reliable than guessing CSS selectors). Pass diff:true to get only what changed since the last snapshot of this tab - far cheaper in a click/read loop. Password fields appear as secret:true with no value.', inputSchema: { interactiveOnly: zod_1.z.boolean().optional(), max: zod_1.z.number().optional(), diff: zod_1.z.boolean().describe('Return added/removed/changed elements since the previous snapshot of this tab instead of the whole tree').optional(), failOnAuthWall: authWallField, ...FRAME_PROPS, tabId: tabIdField } },
|
|
126
|
+
{ name: 'page_watch', description: 'Save a local page checkpoint and compare it later. Actions: create (name), compare (watchId), list, delete. Stores accessibility labels and state, never form values. Site allowlist still applies.', inputSchema: { action: zod_1.z.enum(['create', 'compare', 'list', 'delete']), name: zod_1.z.string().optional(), watchId: zod_1.z.string().optional(), tabId: tabIdField } },
|
|
125
127
|
{ name: 'get_cookies', description: "Read cookies visible to the tab's URL (or a given url).", inputSchema: { url: zod_1.z.string().optional(), tabId: tabIdField } },
|
|
126
128
|
{ name: 'storage', description: 'Read/write localStorage (or sessionStorage). op: get|set|remove|clear.', inputSchema: { op: zod_1.z.enum(['get', 'set', 'remove', 'clear']), key: zod_1.z.string().optional(), value: zod_1.z.string().optional(), session: zod_1.z.boolean().optional(), tabId: tabIdField } },
|
|
127
129
|
{ name: 'eval', description: 'Evaluate JavaScript in the page (disabled in safe-mode).', inputSchema: { expression: zod_1.z.string(), awaitPromise: zod_1.z.boolean().optional(), ...FRAME_PROPS, tabId: tabIdField } },
|
|
@@ -304,6 +306,7 @@ const TOOL_GATE = {
|
|
|
304
306
|
get_text: 'get_text',
|
|
305
307
|
get_html: 'get_html',
|
|
306
308
|
snapshot: 'get_text',
|
|
309
|
+
page_watch: 'get_text',
|
|
307
310
|
get_cookies: 'get_text',
|
|
308
311
|
extract_links: 'get_text',
|
|
309
312
|
read_as_markdown: 'get_text',
|
|
@@ -704,14 +707,27 @@ exports.TOOL_HANDLERS = {
|
|
|
704
707
|
tabs_list: async (_a, ctx) => (0, envelopes_1.jsonResult)(await ctx.ex.tabsList()),
|
|
705
708
|
tab_select: async (a, ctx) => {
|
|
706
709
|
await gate(ctx, 'tab_select');
|
|
707
|
-
const
|
|
710
|
+
const id = (0, validators_1.requireString)(a, 'tabId');
|
|
711
|
+
// Showing the tab pulls the person off whatever they are doing, so only on request.
|
|
712
|
+
if ((0, validators_1.optionalBoolean)(a, 'show') === true) {
|
|
713
|
+
const tab = await ctx.ex.tabSelect(id);
|
|
714
|
+
workTab = tab.tabId; // an explicit pick: later calls follow it
|
|
715
|
+
return (0, envelopes_1.jsonResult)(tab);
|
|
716
|
+
}
|
|
717
|
+
const tab = (await ctx.ex.tabsList()).find((t) => t.tabId === id);
|
|
718
|
+
if (!tab)
|
|
719
|
+
throw new validators_1.McpToolError(`no open tab with tabId ${id}; call tabs_list for the current ids`);
|
|
708
720
|
workTab = tab.tabId; // an explicit pick: later calls follow it
|
|
709
|
-
return (0, envelopes_1.jsonResult)(tab);
|
|
721
|
+
return (0, envelopes_1.jsonResult)({ ...tab, shown: false });
|
|
710
722
|
},
|
|
711
723
|
tab_new: async (a, ctx) => {
|
|
712
724
|
await gate(ctx, 'tab_new');
|
|
725
|
+
// Opening a tab at a URL loads it like navigate does, so gate it the same way.
|
|
726
|
+
const url = (0, validators_1.optionalString)(a, 'url');
|
|
727
|
+
if (url !== undefined)
|
|
728
|
+
await gate(ctx, 'navigate', { url });
|
|
713
729
|
// Background unless asked: focusing it would pull the person off their tab.
|
|
714
|
-
const tab = await ctx.ex.tabNew(
|
|
730
|
+
const tab = await ctx.ex.tabNew(url, { active: (0, validators_1.optionalBoolean)(a, 'active') ?? false });
|
|
715
731
|
workTab = tab.tabId;
|
|
716
732
|
return (0, envelopes_1.jsonResult)(tab);
|
|
717
733
|
},
|
|
@@ -917,9 +933,39 @@ exports.TOOL_HANDLERS = {
|
|
|
917
933
|
: {}),
|
|
918
934
|
});
|
|
919
935
|
},
|
|
936
|
+
page_watch: async (a, ctx) => {
|
|
937
|
+
const action = (0, validators_1.requireString)(a, 'action');
|
|
938
|
+
const ws = (0, workspace_1.getActiveWorkspace)();
|
|
939
|
+
if (action === 'list')
|
|
940
|
+
return (0, envelopes_1.jsonResult)({ watches: (0, page_watch_1.listWatches)(ws.profileDir) });
|
|
941
|
+
if (action === 'delete') {
|
|
942
|
+
(0, page_watch_1.removeWatch)(ws.profileDir, (0, validators_1.requireString)(a, 'watchId'));
|
|
943
|
+
return (0, envelopes_1.jsonResult)({ deleted: true });
|
|
944
|
+
}
|
|
945
|
+
await gate(ctx, 'get_text', { tabId: tabId(a) });
|
|
946
|
+
const snap = await ctx.ex.snapshot({ tabId: tabId(a), ...frameOpts(a), max: 500 });
|
|
947
|
+
const clean = (0, page_watch_1.safeSnapshot)(snap, (s) => (0, redact_1.redactText)(s, redaction(ctx.policy)).value);
|
|
948
|
+
if (action === 'create') {
|
|
949
|
+
const name = (0, validators_1.requireWithinLength)((0, validators_1.requireString)(a, 'name'), 'name', 80);
|
|
950
|
+
const saved = (0, page_watch_1.createWatch)(ws.profileDir, name, clean);
|
|
951
|
+
return (0, envelopes_1.jsonResult)({ watchId: saved.id, name: saved.name, url: saved.url, title: clean.title, elements: clean.nodes.length, savedAt: new Date(saved.ts).toISOString(), note: 'Checkpoint saved locally for this Chrome profile. Form values are not stored.' });
|
|
952
|
+
}
|
|
953
|
+
if (action === 'compare') {
|
|
954
|
+
const previous = (0, page_watch_1.readWatch)(ws.profileDir, (0, validators_1.requireString)(a, 'watchId'));
|
|
955
|
+
// The site's grant may have been revoked since the checkpoint was saved.
|
|
956
|
+
(0, policy_1.assertUrlAllowed)(previous.url, 'get_text', ctx.policy);
|
|
957
|
+
const diff = (0, page_watch_1.compareWatch)(previous, clean, (s) => (0, redact_1.redactText)(s, redaction(ctx.policy)).value);
|
|
958
|
+
return (0, envelopes_1.jsonResult)({ watchId: previous.id, name: previous.name, previousUrl: previous.url, currentUrl: clean.url, previousAt: new Date(previous.ts).toISOString(), checkedAt: new Date().toISOString(), ...diff });
|
|
959
|
+
}
|
|
960
|
+
throw new validators_1.McpToolError(`unknown page_watch action: ${action}`);
|
|
961
|
+
},
|
|
920
962
|
get_cookies: async (a, ctx) => {
|
|
921
963
|
await gate(ctx, 'get_text', { tabId: tabId(a) }); // reads tab-scoped secrets; same domain gate as content reads
|
|
922
|
-
|
|
964
|
+
// Cookies for another URL are that site's secrets: it must be allowed too.
|
|
965
|
+
const url = (0, validators_1.optionalString)(a, 'url');
|
|
966
|
+
if (url !== undefined)
|
|
967
|
+
await gate(ctx, 'get_text', { url });
|
|
968
|
+
return (0, envelopes_1.jsonResult)(await ctx.ex.getCookies({ tabId: tabId(a), url }));
|
|
923
969
|
},
|
|
924
970
|
storage: async (a, ctx) => {
|
|
925
971
|
const op = (0, validators_1.requireString)(a, 'op');
|
|
@@ -1025,11 +1071,14 @@ exports.TOOL_HANDLERS = {
|
|
|
1025
1071
|
return (0, envelopes_1.jsonResult)(res);
|
|
1026
1072
|
},
|
|
1027
1073
|
download_file: async (a, ctx) => {
|
|
1028
|
-
await gate(ctx, 'download_file');
|
|
1029
1074
|
const url = (0, validators_1.optionalString)(a, 'url');
|
|
1030
1075
|
const target = (0, validators_1.optionalTarget)(a);
|
|
1031
1076
|
if (!url && !target)
|
|
1032
1077
|
throw new validators_1.McpToolError('provide "url" or a target (selector|ref)');
|
|
1078
|
+
// By URL, the bytes come from that URL with the person's cookies: gate the
|
|
1079
|
+
// URL, not whichever tab the agent is on. By element, gate the tab it is in
|
|
1080
|
+
// (the extension then gates where the link leads).
|
|
1081
|
+
await gate(ctx, 'download_file', url ? { url } : { tabId: tabId(a) });
|
|
1033
1082
|
return (0, envelopes_1.jsonResult)(await ctx.ex.download({ url, target, tabId: tabId(a), suggestedName: (0, validators_1.optionalString)(a, 'suggestedName') }));
|
|
1034
1083
|
},
|
|
1035
1084
|
upload_file: async (a, ctx) => {
|
|
@@ -1045,9 +1094,20 @@ exports.TOOL_HANDLERS = {
|
|
|
1045
1094
|
if (!ctx.policy.uploadsDir) {
|
|
1046
1095
|
throw new validators_1.McpToolError('upload denied: uploads require an --uploads-dir to be configured (refusing unrestricted local-file access)');
|
|
1047
1096
|
}
|
|
1048
|
-
|
|
1097
|
+
// Compare real paths: Chrome reads a symlink's target, so a link inside the
|
|
1098
|
+
// dir pointing outside it must be judged by where it points.
|
|
1099
|
+
const real = (p) => {
|
|
1100
|
+
try {
|
|
1101
|
+
return (0, node_fs_1.realpathSync)(p);
|
|
1102
|
+
}
|
|
1103
|
+
catch {
|
|
1104
|
+
// Not there (yet): resolve its folder, so /tmp vs /private/tmp still compare.
|
|
1105
|
+
return p === (0, node_path_1.dirname)(p) ? p : (0, node_path_1.join)(real((0, node_path_1.dirname)(p)), (0, node_path_1.basename)(p));
|
|
1106
|
+
}
|
|
1107
|
+
};
|
|
1108
|
+
const dir = real((0, node_path_1.resolve)(ctx.policy.uploadsDir));
|
|
1049
1109
|
for (const f of files) {
|
|
1050
|
-
const abs = (0, node_path_1.resolve)(f);
|
|
1110
|
+
const abs = real((0, node_path_1.resolve)(f));
|
|
1051
1111
|
if (abs !== dir && !abs.startsWith(dir + node_path_1.sep)) {
|
|
1052
1112
|
throw new validators_1.McpToolError(`upload denied: "${f}" is outside the allowed uploads dir (${dir})`);
|
|
1053
1113
|
}
|
|
@@ -1378,7 +1438,11 @@ async function dispatchInner(name, handler, rawArgs, audit, started) {
|
|
|
1378
1438
|
const mgr = (0, manager_1.getManager)();
|
|
1379
1439
|
// Workspace-management tools run server-side and must work even with no
|
|
1380
1440
|
// browser paired, so they skip the executor readiness check.
|
|
1381
|
-
const
|
|
1441
|
+
const args = (0, validators_1.asArgs)(rawArgs);
|
|
1442
|
+
const workspaceOnlyWatchAction = name === 'page_watch' && (args.action === 'list' || args.action === 'delete');
|
|
1443
|
+
const ex = NO_BACKEND_TOOLS.has(name) || workspaceOnlyWatchAction
|
|
1444
|
+
? null
|
|
1445
|
+
: await mgr.ensureReady();
|
|
1382
1446
|
let result;
|
|
1383
1447
|
try {
|
|
1384
1448
|
result = await handler((0, validators_1.asArgs)(rawArgs), { ex, policy: mgr.policy });
|
|
@@ -41,6 +41,13 @@ export interface Policy extends WirePolicy {
|
|
|
41
41
|
export declare const DEFAULT_POLICY: Readonly<Policy>;
|
|
42
42
|
/** Merge a partial (from a policy file and/or CLI flags) over the safe default. */
|
|
43
43
|
export declare function resolvePolicy(partial?: Partial<Policy>): Policy;
|
|
44
|
+
/**
|
|
45
|
+
* The wire-serializable subset delivered to the extension in `welcome`, so it
|
|
46
|
+
* mirrors the server gate. `uploadsDir` (a local path) stays server-side.
|
|
47
|
+
* `allowDomains` is passed BY REFERENCE: sites allowed from the extension's
|
|
48
|
+
* Options are spliced into this same array, which the tool gate reads too.
|
|
49
|
+
*/
|
|
50
|
+
export declare function toWirePolicy(policy: Policy): WirePolicy;
|
|
44
51
|
/**
|
|
45
52
|
* Throw `POLICY_DENIED` unless `method` against `url` is permitted by `policy`.
|
|
46
53
|
* `url` is the DESTINATION for navigation, otherwise the current tab URL.
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
24
|
exports.DEFAULT_POLICY = exports.isDomainAllowed = exports.hostOf = exports.isUrlGated = exports.isMutatingMethod = exports.isReadMethod = void 0;
|
|
25
25
|
exports.resolvePolicy = resolvePolicy;
|
|
26
|
+
exports.toWirePolicy = toWirePolicy;
|
|
26
27
|
exports.assertUrlAllowed = assertUrlAllowed;
|
|
27
28
|
const types_1 = require("../executor/types");
|
|
28
29
|
const policy_1 = require("../../shared/policy");
|
|
@@ -61,6 +62,16 @@ function resolvePolicy(partial) {
|
|
|
61
62
|
failOnAuthWall: partial?.failOnAuthWall ?? exports.DEFAULT_POLICY.failOnAuthWall,
|
|
62
63
|
};
|
|
63
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* The wire-serializable subset delivered to the extension in `welcome`, so it
|
|
67
|
+
* mirrors the server gate. `uploadsDir` (a local path) stays server-side.
|
|
68
|
+
* `allowDomains` is passed BY REFERENCE: sites allowed from the extension's
|
|
69
|
+
* Options are spliced into this same array, which the tool gate reads too.
|
|
70
|
+
*/
|
|
71
|
+
function toWirePolicy(policy) {
|
|
72
|
+
const { allowDomains, allowEval, allowDownloads, allowUploads, allowAllTabs, enableMutations, allowObservers } = policy;
|
|
73
|
+
return { allowDomains, allowEval, allowDownloads, allowUploads, allowAllTabs, enableMutations, allowObservers };
|
|
74
|
+
}
|
|
64
75
|
// ---------------------------------------------------------------------------
|
|
65
76
|
// The gate
|
|
66
77
|
// ---------------------------------------------------------------------------
|