looking-glass-mcp 3.0.1 → 3.1.1
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 +95 -13
- package/bin/looking-glass-mcp.mjs +7 -1
- package/bin/looking-glass-vault.mjs +2 -0
- package/build/index.js +117 -44
- package/build/vault-cli.js +261 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -6,7 +6,21 @@
|
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
8
|
<strong>The AI-native browser for agents.</strong><br/>
|
|
9
|
-
|
|
9
|
+
71 MCP tools. Self-healing interactions. Semantic change detection. Structured extraction. Credential vault. Enterprise-grade security. Deploy anywhere.
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
<p align="center">
|
|
13
|
+
<a href="https://www.npmjs.com/package/looking-glass-mcp"><img src="https://img.shields.io/npm/v/looking-glass-mcp?color=blue&label=npm" alt="npm" /></a>
|
|
14
|
+
<a href="https://www.npmjs.com/package/looking-glass-mcp"><img src="https://img.shields.io/npm/dm/looking-glass-mcp?color=orange&label=downloads" alt="downloads" /></a>
|
|
15
|
+
<a href="https://registry.modelcontextprotocol.io/servers/io.github.Sahib-Sawhney-WH/looking-glass-mcp"><img src="https://img.shields.io/badge/MCP_Registry-listed-brightgreen" alt="MCP Registry" /></a>
|
|
16
|
+
<a href="https://github.com/Sahib-Sawhney-WH/LookingGlass/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/Sahib-Sawhney-WH/LookingGlass/ci.yml?label=CI" alt="CI" /></a>
|
|
17
|
+
<a href="https://github.com/Sahib-Sawhney-WH/LookingGlass/blob/main/LICENSE"><img src="https://img.shields.io/github/license/Sahib-Sawhney-WH/LookingGlass?color=purple" alt="MIT License" /></a>
|
|
18
|
+
<a href="https://github.com/Sahib-Sawhney-WH/LookingGlass"><img src="https://img.shields.io/github/stars/Sahib-Sawhney-WH/LookingGlass?style=social" alt="GitHub Stars" /></a>
|
|
19
|
+
<img src="https://img.shields.io/badge/tools-71-ff69b4" alt="71 tools" />
|
|
20
|
+
<img src="https://img.shields.io/badge/tests-79-success" alt="79 tests" />
|
|
21
|
+
<img src="https://img.shields.io/badge/node-%3E%3D20-339933?logo=node.js&logoColor=white" alt="Node 20+" />
|
|
22
|
+
<img src="https://img.shields.io/badge/Playwright-1.50+-2EAD33?logo=playwright&logoColor=white" alt="Playwright" />
|
|
23
|
+
<img src="https://img.shields.io/badge/Azure-ready-0078D4?logo=microsoftazure&logoColor=white" alt="Azure Ready" />
|
|
10
24
|
</p>
|
|
11
25
|
|
|
12
26
|
<p align="center">
|
|
@@ -30,7 +44,7 @@ Looking Glass v3.0 transforms from a browser automation tool into an **AI-native
|
|
|
30
44
|
- **Intent-based navigation** -- `browser_go "settings page"` figures out how to get there
|
|
31
45
|
- **Semantic waiting** -- `browser_wait_for "search results loaded"` instead of guessing CSS selectors
|
|
32
46
|
- **Workflow tracking** -- every response includes page type, form progress, breadcrumbs, modal state, and step indicators
|
|
33
|
-
- **Credential vault** -- AES-256-GCM encrypted credentials with PBKDF2 key derivation and blind injection (the agent never sees passwords)
|
|
47
|
+
- **Credential vault** -- AES-256-GCM encrypted credentials with PBKDF2 key derivation and blind injection (the agent never sees passwords). Smart field matching works on modern React forms with no `name` attributes
|
|
34
48
|
- **HTTP transport** -- deploy as a cloud service with `StreamableHTTPServerTransport`
|
|
35
49
|
- **Docker + Terraform** -- one-command Azure deployment with Key Vault, managed identity, and hardened security
|
|
36
50
|
- **Enterprise security** -- timing-safe auth, rate limiting, auth lockout, deep audit logging, non-root containers
|
|
@@ -42,9 +56,22 @@ Looking Glass v3.0 transforms from a browser automation tool into an **AI-native
|
|
|
42
56
|
**Claude Code:**
|
|
43
57
|
|
|
44
58
|
```bash
|
|
59
|
+
# Basic (headless, uses local-dev profile by default for stdio)
|
|
45
60
|
claude mcp add looking-glass -- npx looking-glass-mcp
|
|
61
|
+
|
|
62
|
+
# With visible browser window
|
|
63
|
+
claude mcp add looking-glass -e BROWSER_HEADLESS=false -- npx looking-glass-mcp
|
|
64
|
+
|
|
65
|
+
# With all options
|
|
66
|
+
claude mcp add looking-glass \
|
|
67
|
+
-e BROWSER_HEADLESS=false \
|
|
68
|
+
-e BROWSER_SECURITY_PROFILE=local-dev \
|
|
69
|
+
-e VAULT_ENCRYPTION_KEY=your-passphrase-at-least-32-characters \
|
|
70
|
+
-- npx looking-glass-mcp
|
|
46
71
|
```
|
|
47
72
|
|
|
73
|
+
> **Tip:** After changing env vars, restart Claude Code (new conversation) for the changes to take effect.
|
|
74
|
+
|
|
48
75
|
**GitHub Copilot / VS Code:**
|
|
49
76
|
|
|
50
77
|
Add to `.vscode/mcp.json`:
|
|
@@ -185,16 +212,17 @@ Looking Glass ships with **72 tools** across 10 categories.
|
|
|
185
212
|
| `browser_wait_for` | **NEW** Semantic condition waiting ("results loaded") |
|
|
186
213
|
| `browser_workflow` | **NEW** On-demand workflow context |
|
|
187
214
|
|
|
188
|
-
### Credential Vault (
|
|
215
|
+
### Credential Vault (4 tools)
|
|
189
216
|
|
|
190
217
|
| Tool | Description |
|
|
191
218
|
|------|-------------|
|
|
192
|
-
| `vault_store` | Store encrypted credential profile (AES-256-GCM + PBKDF2) |
|
|
193
219
|
| `vault_list` | List profile names and timestamps (no values) |
|
|
194
220
|
| `vault_delete` | Delete a credential profile |
|
|
195
221
|
| `vault_login` | Login using a vault profile (blind injection) |
|
|
196
222
|
| `vault_inject` | Fill form fields from vault without submitting |
|
|
197
223
|
|
|
224
|
+
> Credentials are stored via the CLI (`looking-glass vault store`), not through the agent. See [Credential Vault](#credential-vault) below.
|
|
225
|
+
|
|
198
226
|
### Observation & Debugging (10 tools)
|
|
199
227
|
|
|
200
228
|
`browser_screenshot`, `browser_snapshot`, `browser_evaluate`, `browser_console_messages`, `browser_network_requests`, `browser_diagnose`, `browser_error_report`, `browser_snapshot_state` / `browser_diff_state`, `browser_action_history`
|
|
@@ -287,12 +315,20 @@ See [SECURITY.md](SECURITY.md) for the full security model.
|
|
|
287
315
|
|
|
288
316
|
### Security Profiles
|
|
289
317
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
318
|
+
The default profile depends on transport mode:
|
|
319
|
+
- **stdio** (local agent): defaults to `local-dev` (full access)
|
|
320
|
+
- **http** (cloud deployment): defaults to `restricted` (observation only)
|
|
321
|
+
|
|
322
|
+
Override with `BROWSER_SECURITY_PROFILE` if needed.
|
|
323
|
+
|
|
324
|
+
| Profile | URL Access | JS Execution | Tool Access | Rate Limit | Use Case |
|
|
325
|
+
|---------|-----------|--------------|-------------|------------|----------|
|
|
326
|
+
| `local-dev` | All HTTP/HTTPS | Allowed | All tools | 60/min | Local development and testing |
|
|
327
|
+
| `restricted` | localhost only | Blocked | Observation only | 30/min | Production monitoring — agent can screenshot, audit, and inspect but cannot click, type, or navigate |
|
|
328
|
+
| `open` | Everything | Allowed | All tools | 120/min | Trusted cloud environments |
|
|
329
|
+
| `sandbox` | Blocked | Blocked | Observation only | 10/min | Maximum lockdown — blocks all URLs by default |
|
|
330
|
+
|
|
331
|
+
> **Note:** If you're using Looking Glass locally via stdio and the agent can't interact with pages, check that your profile is set to `local-dev`. The `restricted` profile is designed for read-only monitoring where the agent should never modify page state.
|
|
296
332
|
|
|
297
333
|
### HTTP Transport Security
|
|
298
334
|
|
|
@@ -303,10 +339,56 @@ See [SECURITY.md](SECURITY.md) for the full security model.
|
|
|
303
339
|
|
|
304
340
|
### Credential Vault
|
|
305
341
|
|
|
342
|
+
Credentials are stored via the CLI — the agent never sees your passwords.
|
|
343
|
+
|
|
344
|
+
**Setup:**
|
|
345
|
+
|
|
346
|
+
1. Set your vault encryption key (add to your shell profile or MCP config):
|
|
347
|
+
|
|
348
|
+
```bash
|
|
349
|
+
export VAULT_ENCRYPTION_KEY="your-passphrase-at-least-32-characters-long"
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
2. Store a credential profile (interactive — passwords are masked):
|
|
353
|
+
|
|
354
|
+
```bash
|
|
355
|
+
npx looking-glass-mcp vault store linkedin
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
You'll be prompted for field name/value pairs:
|
|
359
|
+
|
|
360
|
+
```
|
|
361
|
+
Storing credential profile: linkedin
|
|
362
|
+
Field name (empty to finish): email
|
|
363
|
+
Value for "email": user@example.com
|
|
364
|
+
Field name (empty to finish): password
|
|
365
|
+
Value for "password": ********
|
|
366
|
+
Field name (empty to finish):
|
|
367
|
+
✓ Stored profile "linkedin" with 2 fields
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
3. Use the profile in your agent conversation:
|
|
371
|
+
|
|
372
|
+
```
|
|
373
|
+
"Log in to LinkedIn with vault profile linkedin"
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
The agent calls `vault_login` which decrypts and injects credentials directly into the page — the agent never sees the plaintext values.
|
|
377
|
+
|
|
378
|
+
**CLI commands:**
|
|
379
|
+
|
|
380
|
+
| Command | Description |
|
|
381
|
+
|---------|-------------|
|
|
382
|
+
| `npx looking-glass-mcp vault store <profile>` | Store credentials interactively (masked input) |
|
|
383
|
+
| `npx looking-glass-mcp vault list` | List stored profiles and timestamps |
|
|
384
|
+
| `npx looking-glass-mcp vault delete <profile>` | Delete a stored profile |
|
|
385
|
+
|
|
386
|
+
**Security properties:**
|
|
387
|
+
|
|
306
388
|
- AES-256-GCM encryption with PBKDF2 key derivation (100k iterations, SHA-512)
|
|
307
389
|
- Field names and values encrypted together (no metadata leakage)
|
|
308
390
|
- Vault file permissions restricted to owner (`0600`)
|
|
309
|
-
-
|
|
391
|
+
- Credential storage happens exclusively through the CLI — never through the agent channel
|
|
310
392
|
- `createdAt` and `lastUsedAt` tracking per profile
|
|
311
393
|
|
|
312
394
|
### Audit Trail
|
|
@@ -422,12 +504,12 @@ MCP Client (Claude, Copilot, etc.)
|
|
|
422
504
|
| - Audit Logging (deep redaction) |
|
|
423
505
|
| - RBAC Policy Enforcement |
|
|
424
506
|
| |
|
|
425
|
-
|
|
|
507
|
+
| 71 Tools |
|
|
426
508
|
| - Intelligence (extract, go, wait_for) |
|
|
427
509
|
| - Interaction (click, type, hover) |
|
|
428
510
|
| - Observation (screenshot, snapshot) |
|
|
429
511
|
| - Testing (scenarios, assertions) |
|
|
430
|
-
| - Vault (
|
|
512
|
+
| - Vault (inject, login, list, delete) |
|
|
431
513
|
| |
|
|
432
514
|
| Browser Manager |
|
|
433
515
|
| - Playwright (Chromium/Firefox/WebKit) |
|
package/build/index.js
CHANGED
|
@@ -22641,7 +22641,9 @@ var SECURITY_PROFILES = {
|
|
|
22641
22641
|
}
|
|
22642
22642
|
};
|
|
22643
22643
|
function getConfig() {
|
|
22644
|
-
const
|
|
22644
|
+
const transport = process.env.MCP_TRANSPORT || "stdio";
|
|
22645
|
+
const defaultProfile = transport === "http" ? "restricted" : "local-dev";
|
|
22646
|
+
const profile = process.env.BROWSER_SECURITY_PROFILE || defaultProfile;
|
|
22645
22647
|
return {
|
|
22646
22648
|
headless: process.env.BROWSER_HEADLESS === "true",
|
|
22647
22649
|
sessionsDir: resolve(process.env.BROWSER_SESSIONS_DIR || ".browser-sessions"),
|
|
@@ -23891,6 +23893,7 @@ function formatJournal(entries) {
|
|
|
23891
23893
|
// src/middleware/toolWrapper.ts
|
|
23892
23894
|
init_manager();
|
|
23893
23895
|
var _middlewares = [];
|
|
23896
|
+
var _hasPreSnapshot = false;
|
|
23894
23897
|
var _captureSnapshot = null;
|
|
23895
23898
|
var _detectChanges = null;
|
|
23896
23899
|
var _captureWorkflow = null;
|
|
@@ -23930,7 +23933,7 @@ function wrapToolRegistration(server) {
|
|
|
23930
23933
|
const errorCountBefore = getConsoleMessages().filter((m) => m.type === "error").length;
|
|
23931
23934
|
const failureCountBefore = getNetworkRequests().filter((r) => r.status && r.status >= 400).length;
|
|
23932
23935
|
let preSnapshot = null;
|
|
23933
|
-
if (isInteraction && _captureSnapshot) {
|
|
23936
|
+
if (isInteraction && _captureSnapshot && _hasPreSnapshot) {
|
|
23934
23937
|
try {
|
|
23935
23938
|
preSnapshot = await _captureSnapshot();
|
|
23936
23939
|
} catch {
|
|
@@ -23986,6 +23989,7 @@ function wrapToolRegistration(server) {
|
|
|
23986
23989
|
workflow = await _captureWorkflow();
|
|
23987
23990
|
}
|
|
23988
23991
|
setPendingIntelligence({ changes, workflow });
|
|
23992
|
+
_hasPreSnapshot = true;
|
|
23989
23993
|
} catch {
|
|
23990
23994
|
}
|
|
23991
23995
|
}
|
|
@@ -27404,7 +27408,6 @@ var INTELLIGENCE_TOOLS = /* @__PURE__ */ new Set([
|
|
|
27404
27408
|
"browser_wait_for"
|
|
27405
27409
|
]);
|
|
27406
27410
|
var VAULT_WRITE_TOOLS = /* @__PURE__ */ new Set([
|
|
27407
|
-
"vault_store",
|
|
27408
27411
|
"vault_delete",
|
|
27409
27412
|
"vault_login",
|
|
27410
27413
|
"vault_inject"
|
|
@@ -28927,15 +28930,6 @@ async function saveVault(vault) {
|
|
|
28927
28930
|
} catch {
|
|
28928
28931
|
}
|
|
28929
28932
|
}
|
|
28930
|
-
function encrypt(data) {
|
|
28931
|
-
const key = requireKey();
|
|
28932
|
-
const iv = randomBytes(12);
|
|
28933
|
-
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
28934
|
-
let encrypted = cipher.update(data, "utf-8", "hex");
|
|
28935
|
-
encrypted += cipher.final("hex");
|
|
28936
|
-
const tag = cipher.getAuthTag().toString("hex");
|
|
28937
|
-
return { iv: iv.toString("hex"), encrypted, tag };
|
|
28938
|
-
}
|
|
28939
28933
|
function decrypt(iv, encrypted, tag) {
|
|
28940
28934
|
const key = requireKey();
|
|
28941
28935
|
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "hex"));
|
|
@@ -28944,18 +28938,6 @@ function decrypt(iv, encrypted, tag) {
|
|
|
28944
28938
|
decrypted += decipher.final("utf-8");
|
|
28945
28939
|
return decrypted;
|
|
28946
28940
|
}
|
|
28947
|
-
async function storeCredential(profile, fields) {
|
|
28948
|
-
const vault = await loadVault();
|
|
28949
|
-
const payload = JSON.stringify({ fieldNames: Object.keys(fields), fields });
|
|
28950
|
-
const { iv, encrypted, tag } = encrypt(payload);
|
|
28951
|
-
vault.profiles[profile] = {
|
|
28952
|
-
iv,
|
|
28953
|
-
encrypted,
|
|
28954
|
-
tag,
|
|
28955
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
28956
|
-
};
|
|
28957
|
-
await saveVault(vault);
|
|
28958
|
-
}
|
|
28959
28941
|
async function listProfiles() {
|
|
28960
28942
|
const vault = await loadVault();
|
|
28961
28943
|
return Object.entries(vault.profiles).map(([name, p]) => ({
|
|
@@ -29028,6 +29010,117 @@ async function injectCredentials(profile) {
|
|
|
29028
29010
|
await label.fill(value, { timeout: 3e3 });
|
|
29029
29011
|
injected++;
|
|
29030
29012
|
matched.push(name);
|
|
29013
|
+
filled = true;
|
|
29014
|
+
}
|
|
29015
|
+
} catch {
|
|
29016
|
+
}
|
|
29017
|
+
}
|
|
29018
|
+
if (!filled) {
|
|
29019
|
+
const keywords = nameLower.split(/[\s_-]+/).filter((w) => w.length > 2);
|
|
29020
|
+
if (keywords.length === 0) keywords.push(nameLower);
|
|
29021
|
+
for (const kw of keywords) {
|
|
29022
|
+
if (filled) break;
|
|
29023
|
+
try {
|
|
29024
|
+
const ariaLocator = page.locator(`input[aria-label*="${kw}" i], textarea[aria-label*="${kw}" i]`).first();
|
|
29025
|
+
if (await ariaLocator.isVisible({ timeout: 1e3 })) {
|
|
29026
|
+
await ariaLocator.fill(value, { timeout: 3e3 });
|
|
29027
|
+
injected++;
|
|
29028
|
+
matched.push(name);
|
|
29029
|
+
filled = true;
|
|
29030
|
+
}
|
|
29031
|
+
} catch {
|
|
29032
|
+
}
|
|
29033
|
+
}
|
|
29034
|
+
if (!filled) {
|
|
29035
|
+
for (const kw of keywords) {
|
|
29036
|
+
if (filled) break;
|
|
29037
|
+
try {
|
|
29038
|
+
const phLocator = page.getByPlaceholder(kw, { exact: false }).first();
|
|
29039
|
+
if (await phLocator.isVisible({ timeout: 1e3 })) {
|
|
29040
|
+
await phLocator.fill(value, { timeout: 3e3 });
|
|
29041
|
+
injected++;
|
|
29042
|
+
matched.push(name);
|
|
29043
|
+
filled = true;
|
|
29044
|
+
}
|
|
29045
|
+
} catch {
|
|
29046
|
+
}
|
|
29047
|
+
}
|
|
29048
|
+
}
|
|
29049
|
+
}
|
|
29050
|
+
if (!filled) {
|
|
29051
|
+
try {
|
|
29052
|
+
const keywords = nameLower.split(/[\s_-]+/).filter((w) => w.length > 2);
|
|
29053
|
+
if (keywords.length === 0) keywords.push(nameLower);
|
|
29054
|
+
const selector = await page.evaluate((kws) => {
|
|
29055
|
+
const labels = document.querySelectorAll("label");
|
|
29056
|
+
for (const label of labels) {
|
|
29057
|
+
const labelText = label.textContent?.trim().toLowerCase() || "";
|
|
29058
|
+
if (!kws.some((kw) => labelText.includes(kw))) continue;
|
|
29059
|
+
if (label.htmlFor) {
|
|
29060
|
+
const target = document.getElementById(label.htmlFor);
|
|
29061
|
+
if (target && ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)) {
|
|
29062
|
+
return `#${CSS.escape(label.htmlFor)}`;
|
|
29063
|
+
}
|
|
29064
|
+
}
|
|
29065
|
+
const wrapped = label.querySelector("input, textarea, select");
|
|
29066
|
+
if (wrapped) {
|
|
29067
|
+
const all = [...document.querySelectorAll(wrapped.tagName.toLowerCase())];
|
|
29068
|
+
const idx = all.indexOf(wrapped);
|
|
29069
|
+
if (idx >= 0) return `${wrapped.tagName.toLowerCase()}:nth-of-type(${idx + 1})`;
|
|
29070
|
+
}
|
|
29071
|
+
}
|
|
29072
|
+
for (const input of document.querySelectorAll("input, textarea, select")) {
|
|
29073
|
+
const labelledBy = input.getAttribute("aria-labelledby");
|
|
29074
|
+
if (!labelledBy) continue;
|
|
29075
|
+
const labelEl = document.getElementById(labelledBy);
|
|
29076
|
+
const labelText = labelEl?.textContent?.trim().toLowerCase() || "";
|
|
29077
|
+
if (kws.some((kw) => labelText.includes(kw))) {
|
|
29078
|
+
return `[aria-labelledby="${CSS.escape(labelledBy)}"]`;
|
|
29079
|
+
}
|
|
29080
|
+
}
|
|
29081
|
+
return null;
|
|
29082
|
+
}, keywords);
|
|
29083
|
+
if (selector) {
|
|
29084
|
+
const locator = page.locator(selector).first();
|
|
29085
|
+
if (await locator.isVisible({ timeout: 1e3 })) {
|
|
29086
|
+
await locator.fill(value, { timeout: 3e3 });
|
|
29087
|
+
injected++;
|
|
29088
|
+
matched.push(name);
|
|
29089
|
+
filled = true;
|
|
29090
|
+
}
|
|
29091
|
+
}
|
|
29092
|
+
} catch {
|
|
29093
|
+
}
|
|
29094
|
+
}
|
|
29095
|
+
if (!filled && /email|username|user|login|account/i.test(nameLower)) {
|
|
29096
|
+
try {
|
|
29097
|
+
const hasPasswordField = await page.locator('input[type="password"]').count() > 0;
|
|
29098
|
+
if (hasPasswordField) {
|
|
29099
|
+
const candidates = page.locator('input[type="text"]:visible, input[type="email"]:visible');
|
|
29100
|
+
const count = await candidates.count();
|
|
29101
|
+
for (let i = 0; i < count && !filled; i++) {
|
|
29102
|
+
const candidate = candidates.nth(i);
|
|
29103
|
+
try {
|
|
29104
|
+
const isBeforePassword = await page.evaluate((idx) => {
|
|
29105
|
+
const inputs = [...document.querySelectorAll("input")];
|
|
29106
|
+
const visible = inputs.filter((inp) => {
|
|
29107
|
+
const t = inp.type?.toLowerCase() || "text";
|
|
29108
|
+
return (t === "text" || t === "email") && inp.offsetParent !== null;
|
|
29109
|
+
});
|
|
29110
|
+
const pw = inputs.find((inp) => inp.type === "password");
|
|
29111
|
+
if (!visible[idx] || !pw) return false;
|
|
29112
|
+
return inputs.indexOf(visible[idx]) < inputs.indexOf(pw);
|
|
29113
|
+
}, i);
|
|
29114
|
+
if (isBeforePassword) {
|
|
29115
|
+
await candidate.fill(value, { timeout: 3e3 });
|
|
29116
|
+
injected++;
|
|
29117
|
+
matched.push(name);
|
|
29118
|
+
filled = true;
|
|
29119
|
+
}
|
|
29120
|
+
} catch {
|
|
29121
|
+
continue;
|
|
29122
|
+
}
|
|
29123
|
+
}
|
|
29031
29124
|
}
|
|
29032
29125
|
} catch {
|
|
29033
29126
|
}
|
|
@@ -29208,26 +29301,6 @@ ${JSON.stringify(ctx, null, 2)}` }
|
|
|
29208
29301
|
}
|
|
29209
29302
|
}
|
|
29210
29303
|
);
|
|
29211
|
-
server.tool(
|
|
29212
|
-
"vault_store",
|
|
29213
|
-
{
|
|
29214
|
-
profile: external_exports.string().describe('Profile name (e.g. "staging-admin", "bank-login")'),
|
|
29215
|
-
fields: external_exports.record(external_exports.string()).describe('Credential fields (e.g. { email: "user@example.com", password: "..." })')
|
|
29216
|
-
},
|
|
29217
|
-
async ({ profile, fields }) => {
|
|
29218
|
-
if (!isVaultConfigured()) {
|
|
29219
|
-
return { content: [{ type: "text", text: "Vault not configured. Set VAULT_ENCRYPTION_KEY env var." }], isError: true };
|
|
29220
|
-
}
|
|
29221
|
-
try {
|
|
29222
|
-
await storeCredential(profile, fields);
|
|
29223
|
-
return {
|
|
29224
|
-
content: [{ type: "text", text: `Vault: stored profile "${profile}" with fields: ${Object.keys(fields).join(", ")}` }]
|
|
29225
|
-
};
|
|
29226
|
-
} catch (err) {
|
|
29227
|
-
return { content: [{ type: "text", text: `Vault store failed: ${err.message}` }], isError: true };
|
|
29228
|
-
}
|
|
29229
|
-
}
|
|
29230
|
-
);
|
|
29231
29304
|
server.tool(
|
|
29232
29305
|
"vault_list",
|
|
29233
29306
|
{},
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { createRequire } from 'module';
|
|
2
|
+
const require = createRequire(import.meta.url);
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
// src/cli/vault.ts
|
|
6
|
+
import { createInterface } from "node:readline";
|
|
7
|
+
import { resolve } from "node:path";
|
|
8
|
+
|
|
9
|
+
// src/security/vault.ts
|
|
10
|
+
import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync } from "node:crypto";
|
|
11
|
+
import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
|
|
12
|
+
import { dirname } from "node:path";
|
|
13
|
+
|
|
14
|
+
// src/browser/manager.ts
|
|
15
|
+
import { chromium, firefox, webkit } from "playwright-core";
|
|
16
|
+
var _readyResolve = null;
|
|
17
|
+
var _readyPromise = new Promise((r) => {
|
|
18
|
+
_readyResolve = r;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// src/security/vault.ts
|
|
22
|
+
var PBKDF2_ITERATIONS = 1e5;
|
|
23
|
+
var PBKDF2_KEY_LEN = 32;
|
|
24
|
+
var PBKDF2_DIGEST = "sha512";
|
|
25
|
+
var _vaultFile = "";
|
|
26
|
+
var _rawKey = null;
|
|
27
|
+
var _derivedKey = null;
|
|
28
|
+
var _vaultSalt = null;
|
|
29
|
+
function initVault(sessionsDir, encryptionKey) {
|
|
30
|
+
_vaultFile = `${sessionsDir}/vault.enc`;
|
|
31
|
+
if (encryptionKey && encryptionKey.length >= 32) {
|
|
32
|
+
_rawKey = encryptionKey;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function isVaultConfigured() {
|
|
36
|
+
return _rawKey !== null;
|
|
37
|
+
}
|
|
38
|
+
function requireKey() {
|
|
39
|
+
if (!_rawKey) {
|
|
40
|
+
throw new Error("Vault not configured. Set VAULT_ENCRYPTION_KEY environment variable (32+ chars).");
|
|
41
|
+
}
|
|
42
|
+
if (!_derivedKey) {
|
|
43
|
+
throw new Error("Vault key not derived. Call loadVault() first to initialize.");
|
|
44
|
+
}
|
|
45
|
+
return _derivedKey;
|
|
46
|
+
}
|
|
47
|
+
async function loadVault() {
|
|
48
|
+
try {
|
|
49
|
+
const data = await readFile(_vaultFile, "utf-8");
|
|
50
|
+
const vault = JSON.parse(data);
|
|
51
|
+
if (vault.salt && _rawKey) {
|
|
52
|
+
_vaultSalt = Buffer.from(vault.salt, "hex");
|
|
53
|
+
_derivedKey = pbkdf2Sync(_rawKey, _vaultSalt, PBKDF2_ITERATIONS, PBKDF2_KEY_LEN, PBKDF2_DIGEST);
|
|
54
|
+
}
|
|
55
|
+
return vault;
|
|
56
|
+
} catch {
|
|
57
|
+
if (_rawKey) {
|
|
58
|
+
_vaultSalt = randomBytes(32);
|
|
59
|
+
_derivedKey = pbkdf2Sync(_rawKey, _vaultSalt, PBKDF2_ITERATIONS, PBKDF2_KEY_LEN, PBKDF2_DIGEST);
|
|
60
|
+
}
|
|
61
|
+
return { version: 2, salt: _vaultSalt?.toString("hex") || "", profiles: {} };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async function saveVault(vault) {
|
|
65
|
+
await mkdir(dirname(_vaultFile), { recursive: true });
|
|
66
|
+
await writeFile(_vaultFile, JSON.stringify(vault, null, 2), "utf-8");
|
|
67
|
+
try {
|
|
68
|
+
await chmod(_vaultFile, 384);
|
|
69
|
+
} catch {
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function encrypt(data) {
|
|
73
|
+
const key = requireKey();
|
|
74
|
+
const iv = randomBytes(12);
|
|
75
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
76
|
+
let encrypted = cipher.update(data, "utf-8", "hex");
|
|
77
|
+
encrypted += cipher.final("hex");
|
|
78
|
+
const tag = cipher.getAuthTag().toString("hex");
|
|
79
|
+
return { iv: iv.toString("hex"), encrypted, tag };
|
|
80
|
+
}
|
|
81
|
+
async function storeCredential(profile, fields) {
|
|
82
|
+
const vault = await loadVault();
|
|
83
|
+
const payload = JSON.stringify({ fieldNames: Object.keys(fields), fields });
|
|
84
|
+
const { iv, encrypted, tag } = encrypt(payload);
|
|
85
|
+
vault.profiles[profile] = {
|
|
86
|
+
iv,
|
|
87
|
+
encrypted,
|
|
88
|
+
tag,
|
|
89
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
90
|
+
};
|
|
91
|
+
await saveVault(vault);
|
|
92
|
+
}
|
|
93
|
+
async function listProfiles() {
|
|
94
|
+
const vault = await loadVault();
|
|
95
|
+
return Object.entries(vault.profiles).map(([name, p]) => ({
|
|
96
|
+
name,
|
|
97
|
+
createdAt: p.createdAt,
|
|
98
|
+
lastUsedAt: p.lastUsedAt
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
async function deleteProfile(profile) {
|
|
102
|
+
const vault = await loadVault();
|
|
103
|
+
delete vault.profiles[profile];
|
|
104
|
+
await saveVault(vault);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// src/cli/vault.ts
|
|
108
|
+
var USAGE = `
|
|
109
|
+
Usage: looking-glass vault <command> [options]
|
|
110
|
+
|
|
111
|
+
Commands:
|
|
112
|
+
store <profile> Store credentials interactively (passwords masked)
|
|
113
|
+
list List stored profiles and timestamps
|
|
114
|
+
delete <profile> Delete a stored profile
|
|
115
|
+
|
|
116
|
+
Environment:
|
|
117
|
+
VAULT_ENCRYPTION_KEY Passphrase for encryption (32+ chars, required)
|
|
118
|
+
BROWSER_SESSIONS_DIR Sessions directory (default: .browser-sessions)
|
|
119
|
+
|
|
120
|
+
Examples:
|
|
121
|
+
npx looking-glass-mcp vault store linkedin
|
|
122
|
+
npx looking-glass-mcp vault list
|
|
123
|
+
npx looking-glass-mcp vault delete linkedin
|
|
124
|
+
`.trim();
|
|
125
|
+
function die(msg) {
|
|
126
|
+
console.error(`Error: ${msg}`);
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
function initFromEnv() {
|
|
130
|
+
const sessionsDir = resolve(process.env.BROWSER_SESSIONS_DIR || ".browser-sessions");
|
|
131
|
+
const key = process.env.VAULT_ENCRYPTION_KEY;
|
|
132
|
+
initVault(sessionsDir, key);
|
|
133
|
+
if (!isVaultConfigured()) {
|
|
134
|
+
die("VAULT_ENCRYPTION_KEY is not set or is shorter than 32 characters.");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function prompt(question) {
|
|
138
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
139
|
+
return new Promise((resolve2) => {
|
|
140
|
+
rl.question(question, (answer) => {
|
|
141
|
+
rl.close();
|
|
142
|
+
resolve2(answer.trim());
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
function promptMasked(question) {
|
|
147
|
+
return new Promise((resolve2) => {
|
|
148
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
149
|
+
process.stdout.write(question);
|
|
150
|
+
const stdin = process.stdin;
|
|
151
|
+
const wasRaw = stdin.isRaw;
|
|
152
|
+
if (stdin.isTTY && stdin.setRawMode) {
|
|
153
|
+
stdin.setRawMode(true);
|
|
154
|
+
}
|
|
155
|
+
let value = "";
|
|
156
|
+
const onData = (ch) => {
|
|
157
|
+
const c = ch.toString("utf-8");
|
|
158
|
+
if (c === "\n" || c === "\r" || c === "") {
|
|
159
|
+
if (stdin.isTTY && stdin.setRawMode) {
|
|
160
|
+
stdin.setRawMode(wasRaw ?? false);
|
|
161
|
+
}
|
|
162
|
+
stdin.removeListener("data", onData);
|
|
163
|
+
rl.close();
|
|
164
|
+
process.stdout.write("\n");
|
|
165
|
+
resolve2(value);
|
|
166
|
+
} else if (c === "") {
|
|
167
|
+
process.stdout.write("\n");
|
|
168
|
+
process.exit(1);
|
|
169
|
+
} else if (c === "\x7F" || c === "\b") {
|
|
170
|
+
if (value.length > 0) {
|
|
171
|
+
value = value.slice(0, -1);
|
|
172
|
+
process.stdout.write("\b \b");
|
|
173
|
+
}
|
|
174
|
+
} else {
|
|
175
|
+
value += c;
|
|
176
|
+
process.stdout.write("*");
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
stdin.on("data", onData);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
function isSensitiveField(name) {
|
|
183
|
+
const lower = name.toLowerCase();
|
|
184
|
+
return ["password", "passwd", "pwd", "pass", "secret", "token", "key", "pin", "otp"].some(
|
|
185
|
+
(p) => lower.includes(p)
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
async function cmdStore(profileName) {
|
|
189
|
+
const profile = profileName || await prompt("Profile name: ");
|
|
190
|
+
if (!profile) die("Profile name is required.");
|
|
191
|
+
console.log(`
|
|
192
|
+
Storing credential profile: ${profile}`);
|
|
193
|
+
console.log("Enter field name/value pairs. Leave field name empty to finish.\n");
|
|
194
|
+
const fields = {};
|
|
195
|
+
while (true) {
|
|
196
|
+
const fieldName = await prompt("Field name (empty to finish): ");
|
|
197
|
+
if (!fieldName) break;
|
|
198
|
+
const sensitive = isSensitiveField(fieldName);
|
|
199
|
+
const value = sensitive ? await promptMasked(`Value for "${fieldName}": `) : await prompt(`Value for "${fieldName}": `);
|
|
200
|
+
if (!value) {
|
|
201
|
+
console.log(` Skipping empty field "${fieldName}"`);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
fields[fieldName] = value;
|
|
205
|
+
}
|
|
206
|
+
if (Object.keys(fields).length === 0) {
|
|
207
|
+
die("No fields provided. Nothing stored.");
|
|
208
|
+
}
|
|
209
|
+
await storeCredential(profile, fields);
|
|
210
|
+
console.log(`
|
|
211
|
+
\u2713 Stored profile "${profile}" with ${Object.keys(fields).length} field(s)`);
|
|
212
|
+
}
|
|
213
|
+
async function cmdList() {
|
|
214
|
+
const profiles = await listProfiles();
|
|
215
|
+
if (profiles.length === 0) {
|
|
216
|
+
console.log("No profiles stored.");
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
console.log("Vault Profiles:\n");
|
|
220
|
+
for (const p of profiles) {
|
|
221
|
+
const lastUsed = p.lastUsedAt ? `, last used: ${p.lastUsedAt}` : "";
|
|
222
|
+
console.log(` \u2022 ${p.name} (created: ${p.createdAt}${lastUsed})`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
async function cmdDelete(profileName) {
|
|
226
|
+
const profile = profileName || await prompt("Profile name to delete: ");
|
|
227
|
+
if (!profile) die("Profile name is required.");
|
|
228
|
+
await deleteProfile(profile);
|
|
229
|
+
console.log(`\u2713 Deleted profile "${profile}"`);
|
|
230
|
+
}
|
|
231
|
+
async function main() {
|
|
232
|
+
const args = process.argv.slice(2);
|
|
233
|
+
if (args[0] === "vault") args.shift();
|
|
234
|
+
const command = args[0];
|
|
235
|
+
const target = args[1];
|
|
236
|
+
if (!command || command === "--help" || command === "-h") {
|
|
237
|
+
console.log(USAGE);
|
|
238
|
+
process.exit(0);
|
|
239
|
+
}
|
|
240
|
+
initFromEnv();
|
|
241
|
+
switch (command) {
|
|
242
|
+
case "store":
|
|
243
|
+
await cmdStore(target);
|
|
244
|
+
break;
|
|
245
|
+
case "list":
|
|
246
|
+
await cmdList();
|
|
247
|
+
break;
|
|
248
|
+
case "delete":
|
|
249
|
+
await cmdDelete(target);
|
|
250
|
+
break;
|
|
251
|
+
default:
|
|
252
|
+
console.error(`Unknown command: ${command}
|
|
253
|
+
`);
|
|
254
|
+
console.log(USAGE);
|
|
255
|
+
process.exit(1);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
main().catch((err) => {
|
|
259
|
+
console.error(`Fatal: ${err.message}`);
|
|
260
|
+
process.exit(1);
|
|
261
|
+
});
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "looking-glass-mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.1",
|
|
4
4
|
"mcpName": "io.github.Sahib-Sawhney-WH/looking-glass-mcp",
|
|
5
5
|
"description": "AI-native browser for agents — semantic change detection, self-healing interactions, structured extraction, credential vault, and enterprise Azure deployment",
|
|
6
6
|
"main": "build/index.js",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"bin": {
|
|
9
9
|
"looking-glass-mcp": "bin/looking-glass-mcp.mjs",
|
|
10
|
-
"looking-glass": "bin/looking-glass-mcp.mjs"
|
|
10
|
+
"looking-glass": "bin/looking-glass-mcp.mjs",
|
|
11
|
+
"looking-glass-vault": "bin/looking-glass-vault.mjs"
|
|
11
12
|
},
|
|
12
13
|
"scripts": {
|
|
13
14
|
"build": "node esbuild.config.js",
|