@achamm/veilbrowser 0.3.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/LICENSE +21 -0
- package/README.md +272 -0
- package/dist/browser.d.ts +16 -0
- package/dist/browser.js +43 -0
- package/dist/cdp.d.ts +39 -0
- package/dist/cdp.js +128 -0
- package/dist/human.d.ts +33 -0
- package/dist/human.js +73 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/launcher.d.ts +67 -0
- package/dist/launcher.js +272 -0
- package/dist/mcp.d.ts +1 -0
- package/dist/mcp.js +156 -0
- package/dist/page.d.ts +194 -0
- package/dist/page.js +527 -0
- package/dist/stealth.d.ts +28 -0
- package/dist/stealth.js +74 -0
- package/package.json +54 -0
- package/src/browser.ts +43 -0
- package/src/cdp.ts +133 -0
- package/src/human.ts +82 -0
- package/src/index.ts +6 -0
- package/src/launcher.ts +325 -0
- package/src/mcp.ts +155 -0
- package/src/page.ts +571 -0
- package/src/stealth.ts +103 -0
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Veil MCP server (stdio, JSON-RPC 2.0, newline-delimited).
|
|
3
|
+
*
|
|
4
|
+
* Exposes the Veil browser as tools any MCP-speaking agent can drive — persoje,
|
|
5
|
+
* Claude Code, etc. Hand-rolled (no @modelcontextprotocol/sdk) to keep Veil's
|
|
6
|
+
* zero-dependency story intact. One browser + one active page per server process;
|
|
7
|
+
* extend to a page registry when you need parallel tabs.
|
|
8
|
+
*
|
|
9
|
+
* bun run src/mcp.ts # headful (stealthiest)
|
|
10
|
+
* VEIL_HEADLESS=1 bun run src/mcp.ts
|
|
11
|
+
*/
|
|
12
|
+
import { createInterface } from "node:readline";
|
|
13
|
+
import { Browser } from "./browser.js";
|
|
14
|
+
import type { Page } from "./page.js";
|
|
15
|
+
|
|
16
|
+
let browser: Browser | null = null;
|
|
17
|
+
let page: Page | null = null;
|
|
18
|
+
|
|
19
|
+
async function ensurePage(): Promise<Page> {
|
|
20
|
+
if (!browser) browser = await Browser.launch({ headless: process.env.VEIL_HEADLESS === "1" });
|
|
21
|
+
if (!page) page = await browser.newPage();
|
|
22
|
+
return page;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const TOOLS = [
|
|
26
|
+
{ name: "veil_goto", description: "Navigate the browser to a URL (launches Chrome on first call).",
|
|
27
|
+
inputSchema: { type: "object", properties: { url: { type: "string" } }, required: ["url"] } },
|
|
28
|
+
{ name: "veil_snapshot", description: "Return the page as a numbered list of interactive elements from the accessibility tree. Use the [ref] numbers with veil_click / veil_fill. No CSS selectors needed.",
|
|
29
|
+
inputSchema: { type: "object", properties: {} } },
|
|
30
|
+
{ name: "veil_click", description: "Click an element by its snapshot ref (human-like mouse path).",
|
|
31
|
+
inputSchema: { type: "object", properties: { ref: { type: "number" } }, required: ["ref"] } },
|
|
32
|
+
{ name: "veil_fill", description: "Click a field by ref and type text into it (human cadence).",
|
|
33
|
+
inputSchema: { type: "object", properties: { ref: { type: "number" }, text: { type: "string" } }, required: ["ref", "text"] } },
|
|
34
|
+
{ name: "veil_type", description: "Type text into the currently focused element.",
|
|
35
|
+
inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] } },
|
|
36
|
+
{ name: "veil_screenshot", description: "Capture a PNG screenshot of the page (returned as an image for vision).",
|
|
37
|
+
inputSchema: { type: "object", properties: {} } },
|
|
38
|
+
{ name: "veil_eval", description: "Evaluate a JS expression in the page and return the value.",
|
|
39
|
+
inputSchema: { type: "object", properties: { expression: { type: "string" } }, required: ["expression"] } },
|
|
40
|
+
{ name: "veil_fedcm_enable", description: "Arm FedCM interception BEFORE navigating to a site that shows a Google/federated 'one-tap' sign-in on load. Order: veil_fedcm_enable -> veil_goto the sign-in page -> veil_fedcm_signin. (Skip this for an active 'Sign in with Google' button; veil_fedcm_signin arms itself when you pass a triggerRef.)",
|
|
41
|
+
inputSchema: { type: "object", properties: {} } },
|
|
42
|
+
{ name: "veil_fedcm_signin", description: "Complete a federated ('Sign in with Google', FedCM) login that Chrome renders as a native chooser no click can reach: waits for the intercepted account chooser, selects an account, and returns it. Pass triggerRef to first click an active sign-in button; omit it for one-tap/passive flows (call veil_fedcm_enable before navigating). accountIndex defaults to 0.",
|
|
43
|
+
inputSchema: { type: "object", properties: { triggerRef: { type: "number" }, accountIndex: { type: "number" } } } },
|
|
44
|
+
{ name: "veil_close", description: "Close the browser.", inputSchema: { type: "object", properties: {} } },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
async function callTool(name: string, args: any): Promise<any> {
|
|
48
|
+
if (name === "veil_close") {
|
|
49
|
+
if (browser) await browser.close();
|
|
50
|
+
browser = null;
|
|
51
|
+
page = null;
|
|
52
|
+
return { content: [{ type: "text", text: "closed" }] };
|
|
53
|
+
}
|
|
54
|
+
const p = await ensurePage();
|
|
55
|
+
switch (name) {
|
|
56
|
+
case "veil_goto":
|
|
57
|
+
await p.goto(args.url);
|
|
58
|
+
return text(`navigated to ${await p.url()}`);
|
|
59
|
+
case "veil_snapshot": {
|
|
60
|
+
const s = await p.snapshot();
|
|
61
|
+
return text(`# ${s.title}\n${s.url}\n\n${s.text || "(no interactive elements)"}`);
|
|
62
|
+
}
|
|
63
|
+
case "veil_click":
|
|
64
|
+
await p.click(args.ref);
|
|
65
|
+
return text(`clicked [${args.ref}]`);
|
|
66
|
+
case "veil_fill":
|
|
67
|
+
await p.fill(args.ref, args.text);
|
|
68
|
+
return text(`filled [${args.ref}]`);
|
|
69
|
+
case "veil_type":
|
|
70
|
+
await p.type(args.text);
|
|
71
|
+
return text(`typed ${args.text.length} chars`);
|
|
72
|
+
case "veil_screenshot": {
|
|
73
|
+
const png = await p.screenshot();
|
|
74
|
+
return { content: [{ type: "image", data: png.toString("base64"), mimeType: "image/png" }] };
|
|
75
|
+
}
|
|
76
|
+
case "veil_eval":
|
|
77
|
+
return text(JSON.stringify(await p.evaluate(args.expression)));
|
|
78
|
+
case "veil_fedcm_enable":
|
|
79
|
+
await p.enableFedCm({ autoSelectFirst: false });
|
|
80
|
+
return text("FedCM armed. Navigate to the sign-in page (one-tap fires on load), then call veil_fedcm_signin.");
|
|
81
|
+
case "veil_fedcm_signin": {
|
|
82
|
+
if (args.triggerRef != null) {
|
|
83
|
+
await p.enableFedCm({ autoSelectFirst: false });
|
|
84
|
+
await p.click(args.triggerRef);
|
|
85
|
+
}
|
|
86
|
+
const dialog = await p.waitForFedCmDialog({ timeout: args.timeout ?? 30000 });
|
|
87
|
+
const idx = args.accountIndex ?? 0;
|
|
88
|
+
const account = dialog.accounts[idx];
|
|
89
|
+
if (!account) {
|
|
90
|
+
await p.dismissFedCm();
|
|
91
|
+
throw new Error(`FedCM dialog had ${dialog.accounts.length} account(s); none at index ${idx}`);
|
|
92
|
+
}
|
|
93
|
+
await p.selectFedCmAccount(idx, dialog.dialogId);
|
|
94
|
+
await p.disableFedCm();
|
|
95
|
+
return text(`signed in via FedCM as ${account.email ?? account.name ?? account.accountId}`);
|
|
96
|
+
}
|
|
97
|
+
default:
|
|
98
|
+
throw new Error(`unknown tool: ${name}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const text = (t: string) => ({ content: [{ type: "text", text: t }] });
|
|
103
|
+
|
|
104
|
+
// --- JSON-RPC stdio loop ---
|
|
105
|
+
const send = (msg: any): void => {
|
|
106
|
+
process.stdout.write(JSON.stringify(msg) + "\n");
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
async function handle(msg: any): Promise<void> {
|
|
110
|
+
const { id, method, params } = msg;
|
|
111
|
+
try {
|
|
112
|
+
if (method === "initialize") {
|
|
113
|
+
send({ jsonrpc: "2.0", id, result: {
|
|
114
|
+
protocolVersion: "2024-11-05",
|
|
115
|
+
capabilities: { tools: {} },
|
|
116
|
+
serverInfo: { name: "veil", version: "0.3.0" },
|
|
117
|
+
} });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (method === "notifications/initialized") return; // notification, no reply
|
|
121
|
+
if (method === "tools/list") return send({ jsonrpc: "2.0", id, result: { tools: TOOLS } });
|
|
122
|
+
if (method === "tools/call") {
|
|
123
|
+
const result = await callTool(params.name, params.arguments ?? {});
|
|
124
|
+
return send({ jsonrpc: "2.0", id, result });
|
|
125
|
+
}
|
|
126
|
+
if (id !== undefined) send({ jsonrpc: "2.0", id, error: { code: -32601, message: `method not found: ${method}` } });
|
|
127
|
+
} catch (e: any) {
|
|
128
|
+
if (id !== undefined) send({ jsonrpc: "2.0", id, error: { code: -32603, message: e?.message ?? String(e) } });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Serialize handling: browser state is shared, and a fast request (close) must
|
|
133
|
+
// never overtake a slow one (goto). Chain every message through one promise.
|
|
134
|
+
let chain: Promise<void> = Promise.resolve();
|
|
135
|
+
const rl = createInterface({ input: process.stdin });
|
|
136
|
+
rl.on("line", (line) => {
|
|
137
|
+
const t = line.trim();
|
|
138
|
+
if (!t) return;
|
|
139
|
+
let msg: any;
|
|
140
|
+
try {
|
|
141
|
+
msg = JSON.parse(t);
|
|
142
|
+
} catch {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
chain = chain.then(() => handle(msg)).catch(() => {});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const shutdown = async () => {
|
|
149
|
+
await chain.catch(() => {});
|
|
150
|
+
if (browser) await browser.close().catch(() => {});
|
|
151
|
+
process.exit(0);
|
|
152
|
+
};
|
|
153
|
+
rl.on("close", shutdown); // stdin EOF (e.g. piped input)
|
|
154
|
+
process.on("SIGINT", shutdown);
|
|
155
|
+
process.on("SIGTERM", shutdown);
|