unbrowse 12.2.1-preview.0 → 12.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/SKILL.md CHANGED
@@ -63,7 +63,7 @@ Hosts that need a stdio server or reject dotted tool names can run `npx unbrowse
63
63
  - CLI: `unbrowse discover`, `run`, `inspect`, `resume`, `scrape <url>` (one page as markdown), `index <url>` (teach a site; `index status`), `registry`, `site`, `whoami`. `scrape` and `index` need CLI 12.2.0 or later. `unbrowse run` never drives a website in a browser (the only browser it opens is the first-use sign-in). There is no `unbrowse browse` and no `unbrowse install`. `unbrowse help` describes the installed version.
64
64
  - SDK: REST integration and scripting. Consult the public SDK docs for its supported methods; MCP tools and REST methods are not interchangeable names.
65
65
 
66
- Core MCP tools: `unbrowse_discover`, `unbrowse_run`, `unbrowse_inspect`, `unbrowse_resume`, `unbrowse_cancel`, `unbrowse_scrape`, `unbrowse_map`, `unbrowse_sites`, `unbrowse_usage`, `unbrowse_credits`, `unbrowse_forget`, `unbrowse_learn`, `unbrowse_index`, `unbrowse_index_status`, `unbrowse_credentials_list`, `unbrowse_credentials_request`, `unbrowse_credentials_status`, `unbrowse_connect` (connect an app's own MCP server, such as Linear, Notion or GitHub, so its tools run as the person: an app with sign-in returns a link the person opens), and the cloud browser `unbrowse_browse_open`, `unbrowse_browse_snapshot`, `unbrowse_browse_act`, `unbrowse_browse_finish`, `unbrowse_browse_close`.
66
+ Core MCP tools: `unbrowse_discover`, `unbrowse_run`, `unbrowse_inspect`, `unbrowse_resume`, `unbrowse_cancel`, `unbrowse_scrape`, `unbrowse_map`, `unbrowse_sites`, `unbrowse_usage`, `unbrowse_credits`, `unbrowse_forget`, `unbrowse_learn`, `unbrowse_index`, `unbrowse_index_status`, `unbrowse_credentials_list`, `unbrowse_credentials_request`, `unbrowse_credentials_status`, `unbrowse_connect` (connect an app's own MCP server, such as Linear, Notion or GitHub, so its tools run as the person: an app with sign-in returns a link the person opens), `unbrowse_import_mcps` (bring the MCP servers this agent already has into Unbrowse, below), and the cloud browser `unbrowse_browse_open`, `unbrowse_browse_snapshot`, `unbrowse_browse_act`, `unbrowse_browse_finish`, `unbrowse_browse_close`.
67
67
 
68
68
  [references/tools.json](references/tools.json) contains the exported core MCP input schemas. The connected server's `tools/list` is authoritative: it also includes dynamic tools available to this user's workspace. Never invent a capability ID or input schema.
69
69
 
@@ -112,6 +112,14 @@ To cover a whole site ahead of need, `unbrowse_index {url, focus?, maxCapabiliti
112
112
 
113
113
  An existing HAR pair can be sent through `unbrowse_learn`. Only submit recordings the user authorized; HARs can contain private data. Private and loopback destinations are refused by the hosted service.
114
114
 
115
+ ## Bring the agent's MCP servers
116
+
117
+ When the person wants their existing MCP servers in Unbrowse (one sign-in, every agent gets them), import them instead of re-adding each by hand.
118
+
119
+ - From a terminal: `npx unbrowse mcps` lists the servers in this machine's agent configs (Claude Code, Claude Desktop, Cursor, Codex, VS Code, Windsurf, Gemini, Zed); `npx unbrowse mcps import` imports them (`--dry-run` shows the plan). Keys in those configs stay on the machine unless the person adds `--with-keys`.
120
+ - As an MCP tool: read your host's MCP config yourself and call `unbrowse_import_mcps {servers:[{name, url} | {name, command, args}]}`. Never pass keys, headers or env values; `dryRun: true` shows the plan.
121
+ - Remote servers connect by address. A local (stdio) server cannot run in Unbrowse's cloud: when the catalog has the same app hosted, that is connected instead; otherwise it is `local_only` and stays in the agent. `sign_in` and `key` rows carry the link the person opens. Show them the plan before importing.
122
+
115
123
  ## Website sign-in
116
124
 
117
125
  Unbrowse account sign-in and a website's saved login are separate. Never ask for passwords in chat or type credentials via ordinary tool arguments.
package/dist/cli.js CHANGED
@@ -25,6 +25,9 @@ var __esm = (fn, res, err) => () => {
25
25
  return res;
26
26
  };
27
27
 
28
+ // ../sdk/src/version.ts
29
+ var SDK_VERSION = "12.3.0";
30
+
28
31
  // ../sdk/src/client.ts
29
32
  function apiBase(url) {
30
33
  const u = url.replace(/\/+$/, "");
@@ -35,10 +38,12 @@ class Unbrowse {
35
38
  baseUrl;
36
39
  apiKey;
37
40
  fetchImpl;
41
+ client;
38
42
  constructor(opts = {}) {
39
43
  this.baseUrl = apiBase(opts.baseUrl ?? env("UNBROWSE_BASE_URL") ?? DEFAULT_BASE_URL);
40
44
  this.apiKey = opts.apiKey ?? env("UNBROWSE_API_KEY");
41
45
  this.fetchImpl = opts.fetch ?? ((...a) => globalThis.fetch(...a));
46
+ this.client = (opts.client ?? `sdk/${SDK_VERSION}`).replace(/[^A-Za-z0-9._/-]/g, "").slice(0, 64);
42
47
  }
43
48
  async send(path, init) {
44
49
  const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
@@ -46,6 +51,7 @@ class Unbrowse {
46
51
  headers: {
47
52
  ...this.apiKey ? { authorization: `Bearer ${this.apiKey}` } : {},
48
53
  "content-type": "application/json",
54
+ ...this.client ? { "x-unbrowse-client": this.client } : {},
49
55
  ...init?.headers ?? {}
50
56
  }
51
57
  });
@@ -158,7 +164,15 @@ class Unbrowse {
158
164
  if (finished)
159
165
  return;
160
166
  try {
161
- const next = await this.answerEgress(egressId, req.id, answer);
167
+ let next;
168
+ try {
169
+ next = await this.answerEgress(egressId, req.id, answer);
170
+ } catch (err) {
171
+ if (!(err instanceof UnbrowseError && err.status === 413) || !("response" in answer))
172
+ throw err;
173
+ const bytes = Math.round((answer.response.body?.length ?? 0) * 0.75);
174
+ next = await this.answerEgress(egressId, req.id, { error: `the site's response (${bytes} bytes) is too large to relay from this machine; run it from Unbrowse instead (CLI: --from-unbrowse)` });
175
+ }
162
176
  if (isEgressStep(next) || isEgressWaiting(next))
163
177
  latest = next;
164
178
  else
@@ -232,6 +246,13 @@ class Unbrowse {
232
246
  connect: (a) => this.post("/accounts/connections", a),
233
247
  register: (a) => this.post("/accounts/register", a)
234
248
  };
249
+ connectors = {
250
+ list: () => this.req("/connectors"),
251
+ apps: (q = "", limit = 20) => this.req(`/connectors/apps?q=${encodeURIComponent(q)}&limit=${limit}`),
252
+ connect: (c) => this.post("/connectors/connect", c),
253
+ import: (servers, opts = {}) => this.post("/connectors/import", { servers, ...opts.dryRun ? { dryRun: true } : {} }),
254
+ disconnect: (app) => this.post("/connectors/disconnect", { app })
255
+ };
235
256
  vault() {
236
257
  return this.req("/vault");
237
258
  }
@@ -296,7 +317,10 @@ function isEgressStep(v) {
296
317
  return !!v && typeof v === "object" && v.status === "egress_required";
297
318
  }
298
319
  function isEgressWaiting(v) {
299
- return !!v && typeof v === "object" && v.status === "waiting_for_client" && typeof v.egressId === "string";
320
+ if (!v || typeof v !== "object")
321
+ return false;
322
+ const o = v;
323
+ return typeof o.egressId === "string" && o.runId === undefined && o.status !== "egress_required";
300
324
  }
301
325
  async function sendEgress(send, req) {
302
326
  const body = req.body === undefined ? undefined : req.bodyEncoding === "base64" ? base64ToBytes(req.body) : req.body;
@@ -418,17 +442,208 @@ var init_src = __esm(() => {
418
442
  init_mcp();
419
443
  });
420
444
 
445
+ // src/agent-mcps.ts
446
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
447
+ import { homedir as homedir2 } from "node:os";
448
+ import { join as join2 } from "node:path";
449
+ function parseJsonc(text) {
450
+ let out = "";
451
+ for (let i = 0;i < text.length; i++) {
452
+ const c = text[i];
453
+ if (c === '"') {
454
+ let j = i + 1;
455
+ while (j < text.length && text[j] !== '"')
456
+ j += text[j] === "\\" ? 2 : 1;
457
+ out += text.slice(i, j + 1);
458
+ i = j;
459
+ } else if (c === "/" && text[i + 1] === "/") {
460
+ while (i < text.length && text[i] !== `
461
+ `)
462
+ i++;
463
+ out += `
464
+ `;
465
+ } else if (c === "/" && text[i + 1] === "*") {
466
+ i = text.indexOf("*/", i + 2);
467
+ if (i < 0)
468
+ break;
469
+ i++;
470
+ } else
471
+ out += c;
472
+ }
473
+ return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"));
474
+ }
475
+ function parseCodexToml(text) {
476
+ const servers = {};
477
+ let current;
478
+ let sub;
479
+ const value = (v) => {
480
+ v = v.trim();
481
+ if (/^".*"$/.test(v) || /^'.*'$/.test(v))
482
+ return v.slice(1, -1).replace(/\\"/g, '"');
483
+ if (v.startsWith("["))
484
+ return [...v.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'/g)].map((m) => m[1] ?? m[2]);
485
+ if (v.startsWith("{"))
486
+ return Object.fromEntries([...v.matchAll(/([\w.-]+|"[^"]+")\s*=\s*("(?:[^"\\]|\\.)*"|'[^']*')/g)].map((m) => [m[1].replace(/"/g, ""), m[2].slice(1, -1)]));
487
+ if (v === "true" || v === "false")
488
+ return v === "true";
489
+ return v;
490
+ };
491
+ for (const line of text.split(/\r?\n/)) {
492
+ const t = line.replace(/\s+#.*$/, "").trim();
493
+ if (!t || t.startsWith("#"))
494
+ continue;
495
+ const table = /^\[mcp_servers\.("?)([^\]".]+)\1(?:\.([\w-]+))?\]$/.exec(t);
496
+ if (table) {
497
+ current = servers[table[2]] ??= {};
498
+ sub = table[3];
499
+ if (sub)
500
+ current[sub] ??= {};
501
+ continue;
502
+ }
503
+ if (t.startsWith("[")) {
504
+ current = undefined;
505
+ continue;
506
+ }
507
+ const kv = /^([\w.-]+|"[^"]+")\s*=\s*(.+)$/.exec(t);
508
+ if (!kv || !current)
509
+ continue;
510
+ const key = kv[1].replace(/"/g, "");
511
+ if (sub)
512
+ current[sub][key] = value(kv[2]);
513
+ else
514
+ current[key] = value(kv[2]);
515
+ }
516
+ return servers;
517
+ }
518
+ function places(home = process.env.HOME || process.env.USERPROFILE || homedir2(), cwd = process.cwd(), platform = process.platform, env = process.env) {
519
+ const appData = env.APPDATA ?? join2(home, "AppData", "Roaming");
520
+ const desktop = platform === "darwin" ? join2(home, "Library", "Application Support", "Claude") : platform === "win32" ? join2(appData, "Claude") : join2(env.XDG_CONFIG_HOME ?? join2(home, ".config"), "Claude");
521
+ const code = platform === "darwin" ? join2(home, "Library", "Application Support", "Code", "User") : platform === "win32" ? join2(appData, "Code", "User") : join2(env.XDG_CONFIG_HOME ?? join2(home, ".config"), "Code", "User");
522
+ const zed = platform === "darwin" || platform === "linux" ? join2(env.XDG_CONFIG_HOME ?? join2(home, ".config"), "zed", "settings.json") : join2(appData, "Zed", "settings.json");
523
+ const mcpServers = (t) => asObj(parseJsonc(t).mcpServers);
524
+ return [
525
+ { host: "claude-code", file: join2(home, ".claude.json"), read: (t) => {
526
+ const j = parseJsonc(t);
527
+ const projects = asObj(j.projects);
528
+ return { ...asObj(projects[cwd]?.mcpServers), ...asObj(j.mcpServers) };
529
+ } },
530
+ { host: "claude-code", file: join2(cwd, ".mcp.json"), read: mcpServers },
531
+ { host: "claude-desktop", file: join2(desktop, "claude_desktop_config.json"), read: mcpServers },
532
+ { host: "cursor", file: join2(home, ".cursor", "mcp.json"), read: mcpServers },
533
+ { host: "cursor", file: join2(cwd, ".cursor", "mcp.json"), read: mcpServers },
534
+ { host: "codex", file: join2(env.CODEX_HOME ?? join2(home, ".codex"), "config.toml"), read: parseCodexToml },
535
+ { host: "vscode", file: join2(code, "mcp.json"), read: (t) => asObj(parseJsonc(t).servers) },
536
+ { host: "vscode", file: join2(cwd, ".vscode", "mcp.json"), read: (t) => asObj(parseJsonc(t).servers) },
537
+ { host: "windsurf", file: join2(home, ".codeium", "windsurf", "mcp_config.json"), read: mcpServers },
538
+ { host: "gemini", file: join2(home, ".gemini", "settings.json"), read: mcpServers },
539
+ { host: "zed", file: zed, read: (t) => asObj(parseJsonc(t).context_servers) }
540
+ ];
541
+ }
542
+ function normalize(host, file, name, raw) {
543
+ const zedCommand = asObj(raw.command);
544
+ const command = str(raw.command) ?? str(zedCommand.path);
545
+ const args = (Array.isArray(raw.args) ? raw.args : Array.isArray(zedCommand.args) ? zedCommand.args : []).filter((a) => typeof a === "string").map((a) => secretish(a) && !/^https:\/\/[^\s?#=]+$/.test(a) ? HIDDEN : a);
546
+ const url = str(raw.url) ?? str(raw.serverUrl) ?? str(raw.httpUrl);
547
+ const type = String(raw.type ?? raw.transport ?? "").toLowerCase();
548
+ const transport = url ? type === "sse" || !type && /\/sse\/?$/.test(url) ? "sse" : "streamable-http" : undefined;
549
+ const headers = { ...asObj(raw.headers), ...asObj(raw.http_headers) };
550
+ const env = { ...asObj(raw.env), ...asObj(zedCommand.env) };
551
+ const self = !!url && isUnbrowse(url) || /unbrowse/.test(`${command ?? ""} ${args.join(" ")}`) && args.includes("mcp");
552
+ return { host, file, name, ...url ? { url, transport } : {}, ...command ? { command, args } : {}, headerNames: Object.keys(headers), envNames: Object.keys(env), self };
553
+ }
554
+ function scan(opts = {}) {
555
+ const servers = [];
556
+ const skipped = [];
557
+ const seenFiles = new Set;
558
+ for (const p of places(opts.home, opts.cwd, opts.platform, opts.env)) {
559
+ if (seenFiles.has(p.file) || !existsSync2(p.file))
560
+ continue;
561
+ seenFiles.add(p.file);
562
+ try {
563
+ for (const [name, raw] of Object.entries(p.read(readFileSync2(p.file, "utf8")))) {
564
+ if (raw && typeof raw === "object" && raw.disabled !== true && raw.enabled !== false)
565
+ servers.push(normalize(p.host, p.file, name, raw));
566
+ }
567
+ } catch (e) {
568
+ skipped.push({ file: p.file, why: e.message.slice(0, 120) });
569
+ }
570
+ }
571
+ return { servers, skipped };
572
+ }
573
+ function toImport(f) {
574
+ if (f.url)
575
+ return { name: f.name, url: f.url, ...f.transport ? { transport: f.transport } : {} };
576
+ const args = (f.args ?? []).filter((a) => a !== HIDDEN && (/^https:\/\/[^\s?#]+$/.test(a) || !a.startsWith("-") && !secretish(a) && /^[@\w][\w@./:-]*$/.test(a) && /mcp|server|remote|gateway/i.test(a)));
577
+ return { name: f.name, ...f.command ? { command: f.command.split(/[\\/]/).pop() } : {}, ...args.length ? { args } : {} };
578
+ }
579
+ function keyOf(f, raw) {
580
+ if (!f.url)
581
+ return;
582
+ const headers = { ...asObj(raw.headers), ...asObj(raw.http_headers) };
583
+ for (const [h, v] of Object.entries(headers)) {
584
+ if (typeof v !== "string" || !v.trim() || /\$\{|\$[A-Z_]/.test(v))
585
+ continue;
586
+ if (/^(authorization|x-api-key|api-key|x-auth-token)$/i.test(h))
587
+ return { header: h.toLowerCase(), key: v.trim() };
588
+ }
589
+ return;
590
+ }
591
+ function rawEntry(f, opts = {}) {
592
+ const p = places(opts.home, opts.cwd, opts.platform, opts.env).find((x) => x.file === f.file && x.host === f.host);
593
+ try {
594
+ return p ? p.read(readFileSync2(f.file, "utf8"))[f.name] : undefined;
595
+ } catch {
596
+ return;
597
+ }
598
+ }
599
+ function unique(servers) {
600
+ const seen = new Set;
601
+ return servers.filter((s) => {
602
+ const k = s.url ? `u:${s.url.replace(/\/+$/, "")}` : `c:${s.command} ${(s.args ?? []).join(" ")}`;
603
+ if (seen.has(k))
604
+ return false;
605
+ seen.add(k);
606
+ return true;
607
+ });
608
+ }
609
+ var asObj = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : {}, str = (v) => typeof v === "string" && v.trim() ? v.trim() : undefined, secretish = (a) => /[=]/.test(a) || /(token|secret|key|password|bearer)/i.test(a) || /[A-Za-z0-9_\-]{32,}/.test(a) || /^(sk|ghp|gho|xox[abp]|ub_live|AKIA)[-_A-Za-z0-9]/.test(a), HIDDEN = "‹hidden›", isUnbrowse = (url) => /^https?:\/\/((www|v3|stage|api)\.)?unbrowse\.ai(\/|$)/i.test(url) || /^https?:\/\/[a-z0-9-]+-mcp\.unbrowse\.ai(\/|$)/i.test(url);
610
+ var init_agent_mcps = () => {};
611
+
421
612
  // src/mcp-proxy.ts
422
613
  import { createInterface } from "node:readline";
423
614
  function safeToolName(name) {
424
615
  return name.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64);
425
616
  }
426
- function createProxy(upstream, version) {
617
+ async function localMcps(args, upstream) {
618
+ const A = await Promise.resolve().then(() => (init_agent_mcps(), {}));
619
+ const { servers, skipped } = scan();
620
+ const names = Array.isArray(args.names) ? new Set(args.names.map(String)) : undefined;
621
+ const pick = servers.filter((s) => !names || names.has(s.name));
622
+ const listing = pick.map(({ file: _f, ...s }) => s);
623
+ if (args.import !== true) {
624
+ return { content: [{ type: "text", text: JSON.stringify({ servers: listing, skipped: skipped.map((x) => x.why) }, null, 2) }], structuredContent: { servers: listing }, isError: false };
625
+ }
626
+ const send = unique(pick.filter((s) => !s.self)).map(toImport);
627
+ if (!send.length)
628
+ return { content: [{ type: "text", text: "No MCP servers to import." }], isError: false };
629
+ return (await upstream()).rpc("tools/call", { name: "unbrowse_import_mcps", arguments: { servers: send, ...args.dryRun === true ? { dryRun: true } : {} } });
630
+ }
631
+ function localRun(rest, log = (l) => void process.stderr.write(`${l}
632
+ `)) {
633
+ return async (args) => {
634
+ const request = Object.fromEntries(RUN_KEYS.filter((k) => args[k] !== undefined).map((k) => [k, args[k]]));
635
+ const deadline = Number(args.deadlineMs);
636
+ const view = await (await rest()).runOnClient(request, { onRequest: (r) => log(`→ ${r.method} ${r.url}`), timeoutMs: Math.max(120000, Number.isFinite(deadline) ? deadline : 0) });
637
+ const text = JSON.stringify(view);
638
+ return { content: [{ type: "text", text: text.length > RESULT_MAX ? `${text.slice(0, RESULT_MAX)}…` : text }], structuredContent: view, isError: false };
639
+ };
640
+ }
641
+ function createProxy(upstream, version, opts = {}) {
427
642
  const names = new Map;
428
643
  async function list() {
429
644
  const tools = await (await upstream()).listTools();
430
645
  names.clear();
431
- const out = [];
646
+ const out = [LOCAL_MCPS];
432
647
  for (const t of tools) {
433
648
  const safe = safeToolName(t.name);
434
649
  if (names.has(safe))
@@ -464,6 +679,10 @@ function createProxy(upstream, version) {
464
679
  return reply({ tools: await list() });
465
680
  case "tools/call": {
466
681
  const name = String(msg.params?.name ?? "");
682
+ if (name === LOCAL_MCPS.name)
683
+ return reply(await localMcps(msg.params?.arguments ?? {}, upstream));
684
+ if (opts.runLocal && await original(name) === "unbrowse_run")
685
+ return reply(await opts.runLocal(msg.params?.arguments ?? {}));
467
686
  return reply(await (await upstream()).rpc("tools/call", { ...msg.params, name: await original(name) }));
468
687
  }
469
688
  default:
@@ -478,7 +697,7 @@ function createProxy(upstream, version) {
478
697
  };
479
698
  }
480
699
  async function serveStdio(opts) {
481
- const handle = createProxy(async () => new UnbrowseMcp({ url: opts.url, apiKey: await opts.token() ?? "", endUser: opts.endUser, client: `cli-mcp/${opts.version}` }), opts.version);
700
+ const handle = createProxy(async () => new UnbrowseMcp({ url: opts.url, apiKey: await opts.token() ?? "", endUser: opts.endUser, client: `cli-mcp/${opts.version}` }), opts.version, opts.fromHere ? { runLocal: localRun(opts.fromHere) } : {});
482
701
  const write = (m) => new Promise((resolve) => process.stdout.write(`${JSON.stringify(m)}
483
702
  `, () => resolve()));
484
703
  const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
@@ -499,10 +718,24 @@ async function serveStdio(opts) {
499
718
  }
500
719
  await Promise.all(pending);
501
720
  }
502
- var PROTOCOLS;
721
+ var PROTOCOLS, LOCAL_MCPS, RUN_KEYS, RESULT_MAX = 40000;
503
722
  var init_mcp_proxy = __esm(() => {
504
723
  init_src();
505
724
  PROTOCOLS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
725
+ LOCAL_MCPS = {
726
+ name: "unbrowse_local_mcps",
727
+ description: "The MCP servers configured in this machine's agents (Claude Code, Claude Desktop, Cursor, Codex, VS Code, Windsurf, Gemini, Zed), read by this local Unbrowse server: name, agent, remote address or local command, and the NAMES of any headers and env vars (never their values). import: true brings them into Unbrowse through unbrowse_import_mcps (remote ones by address, local ones as their hosted app; Unbrowse itself and duplicates skipped); dryRun: true shows that plan. Keys stay on this machine; for a server that needs one, the person opens the returned link, or runs `unbrowse mcps import --with-keys` in a terminal.",
728
+ inputSchema: {
729
+ type: "object",
730
+ properties: {
731
+ import: { type: "boolean", description: "Import them into Unbrowse" },
732
+ dryRun: { type: "boolean", description: "With import: show the plan without connecting anything" },
733
+ names: { type: "array", items: { type: "string" }, description: "Only these servers (by their config name)" }
734
+ },
735
+ additionalProperties: false
736
+ }
737
+ };
738
+ RUN_KEYS = ["capability", "task", "targetUrl", "input", "accountRefs", "interactionMode", "idempotencyKey", "allowedDestinations"];
506
739
  });
507
740
 
508
741
  // src/cookie-daemon.ts
@@ -532,9 +765,9 @@ __export(exports_cookie_daemon, {
532
765
  writeJson: () => writeJson
533
766
  });
534
767
  import { createHash as createHash2 } from "node:crypto";
535
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
536
- import { homedir as homedir2, platform } from "node:os";
537
- import { dirname as dirname2, join as join2 } from "node:path";
768
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
769
+ import { homedir as homedir3, platform } from "node:os";
770
+ import { dirname as dirname2, join as join3 } from "node:path";
538
771
  function siteOf(domain) {
539
772
  const labels = domain.replace(/^\./, "").toLowerCase().split(".");
540
773
  if (labels.length <= 2)
@@ -547,7 +780,7 @@ function configDir(cliConfigPath) {
547
780
  }
548
781
  function readJson(path, fallback) {
549
782
  try {
550
- return existsSync2(path) ? JSON.parse(readFileSync2(path, "utf8")) : fallback;
783
+ return existsSync3(path) ? JSON.parse(readFileSync3(path, "utf8")) : fallback;
551
784
  } catch {
552
785
  return fallback;
553
786
  }
@@ -691,10 +924,10 @@ async function watch(opts) {
691
924
  }
692
925
  function servicePlan(opts) {
693
926
  const os = opts.os ?? platform();
694
- const home = opts.home ?? homedir2();
927
+ const home = opts.home ?? homedir3();
695
928
  const env = Object.entries(opts.env ?? {});
696
929
  if (os === "linux") {
697
- const path = join2(home, ".config", "systemd", "user", `${SERVICE}.service`);
930
+ const path = join3(home, ".config", "systemd", "user", `${SERVICE}.service`);
698
931
  const content = [
699
932
  "[Unit]",
700
933
  "Description=Unbrowse cookie sync: keeps your browser sign-ins for chosen sites in sync with Unbrowse",
@@ -722,8 +955,8 @@ function servicePlan(opts) {
722
955
  };
723
956
  }
724
957
  if (os === "darwin") {
725
- const path = join2(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
726
- const logPath = join2(home, "Library", "Logs", `${SERVICE}.log`);
958
+ const path = join3(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
959
+ const logPath = join3(home, "Library", "Logs", `${SERVICE}.log`);
727
960
  const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
728
961
  const content = `<?xml version="1.0" encoding="UTF-8"?>
729
962
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -761,7 +994,7 @@ function quoteSystemd(s) {
761
994
  function ephemeralCli(cli) {
762
995
  return /[\\/](_npx|\.npm[\\/]_npx|npx-[^\\/]+|tmp|Temp)[\\/]/i.test(cli);
763
996
  }
764
- var ALL_SITES = "*", allSites = (cfg) => cfg.sites.includes(ALL_SITES), UPLOAD_BATCH = 500, TWO_LEVEL, DEFAULT_INTERVAL_MIN = 15, REFRESH_MS, SERVICE = "unbrowse-cookies", LAUNCHD_LABEL = "ai.unbrowse.cookies", configFile = (dir) => join2(dir, "cookie-sync.json"), stateFile = (dir) => join2(dir, "cookie-sync.state.json");
997
+ var ALL_SITES = "*", allSites = (cfg) => cfg.sites.includes(ALL_SITES), UPLOAD_BATCH = 500, TWO_LEVEL, DEFAULT_INTERVAL_MIN = 15, REFRESH_MS, SERVICE = "unbrowse-cookies", LAUNCHD_LABEL = "ai.unbrowse.cookies", configFile = (dir) => join3(dir, "cookie-sync.json"), stateFile = (dir) => join3(dir, "cookie-sync.state.json");
765
998
  var init_cookie_daemon = __esm(() => {
766
999
  TWO_LEVEL = /^(co|com|net|org|gov|edu|ac|or|ne|go)\.[a-z]{2}$/;
767
1000
  REFRESH_MS = 24 * 60 * 60000;
@@ -985,18 +1218,18 @@ var HEADER = 100;
985
1218
  // src/cookies.ts
986
1219
  import { createDecipheriv, createHash as createHash3, pbkdf2Sync } from "node:crypto";
987
1220
  import { execFileSync } from "node:child_process";
988
- import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync3 } from "node:fs";
989
- import { homedir as homedir3, platform as platform2 } from "node:os";
990
- import { join as join3 } from "node:path";
1221
+ import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync4 } from "node:fs";
1222
+ import { homedir as homedir4, platform as platform2 } from "node:os";
1223
+ import { join as join4 } from "node:path";
991
1224
  function chromium(name, [mac, linux], keychain) {
992
1225
  return {
993
1226
  name,
994
1227
  engine: "chromium",
995
1228
  keychain: `${keychain} Safe Storage`,
996
1229
  dirs: (h) => ({
997
- darwin: [join3(h, "Library/Application Support", mac)],
998
- linux: [join3(h, ".config", linux), join3(h, ".var/app", flatpakId(name), "config", linux)],
999
- win32: [join3(process.env.LOCALAPPDATA ?? join3(h, "AppData/Local"), mac.replace(/\//g, "\\"), "User Data")]
1230
+ darwin: [join4(h, "Library/Application Support", mac)],
1231
+ linux: [join4(h, ".config", linux), join4(h, ".var/app", flatpakId(name), "config", linux)],
1232
+ win32: [join4(process.env.LOCALAPPDATA ?? join4(h, "AppData/Local"), mac.replace(/\//g, "\\"), "User Data")]
1000
1233
  })
1001
1234
  };
1002
1235
  }
@@ -1005,9 +1238,9 @@ function firefox(name, [mac, linux, linuxHome]) {
1005
1238
  name,
1006
1239
  engine: "firefox",
1007
1240
  dirs: (h) => ({
1008
- darwin: [join3(h, "Library/Application Support", mac)],
1009
- linux: [join3(h, ".config", linux), join3(h, linuxHome ?? `.${name.toLowerCase()}`)],
1010
- win32: [join3(process.env.APPDATA ?? join3(h, "AppData/Roaming"), mac)]
1241
+ darwin: [join4(h, "Library/Application Support", mac)],
1242
+ linux: [join4(h, ".config", linux), join4(h, linuxHome ?? `.${name.toLowerCase()}`)],
1243
+ win32: [join4(process.env.APPDATA ?? join4(h, "AppData/Roaming"), mac)]
1011
1244
  })
1012
1245
  };
1013
1246
  }
@@ -1019,7 +1252,7 @@ function findProfiles() {
1019
1252
  const raw = [];
1020
1253
  for (const def of BROWSERS) {
1021
1254
  for (const base of def.dirs(home())[OS] ?? []) {
1022
- if (!existsSync3(base))
1255
+ if (!existsSync4(base))
1023
1256
  continue;
1024
1257
  raw.push(...def.engine === "chromium" ? chromiumProfiles(def, base) : firefoxProfiles(def, base));
1025
1258
  }
@@ -1040,7 +1273,7 @@ function chromiumProfiles(def, base) {
1040
1273
  const out = [];
1041
1274
  let names = [];
1042
1275
  try {
1043
- const local = JSON.parse(readFileSync3(join3(base, "Local State"), "utf8"));
1276
+ const local = JSON.parse(readFileSync4(join4(base, "Local State"), "utf8"));
1044
1277
  names = Object.keys(local.profile?.info_cache ?? {});
1045
1278
  } catch {
1046
1279
  names = ["Default"];
@@ -1048,8 +1281,8 @@ function chromiumProfiles(def, base) {
1048
1281
  if (!names.length)
1049
1282
  names = ["Default"];
1050
1283
  for (const dir of names) {
1051
- const path = join3(base, dir);
1052
- if (existsSync3(join3(path, "Cookies")) || existsSync3(join3(path, "Network", "Cookies"))) {
1284
+ const path = join4(base, dir);
1285
+ if (existsSync4(join4(path, "Cookies")) || existsSync4(join4(path, "Network", "Cookies"))) {
1053
1286
  out.push({ browser: def.name, profile: dir, label: `${def.name} · ${dir}`, path, engine: "chromium" });
1054
1287
  }
1055
1288
  }
@@ -1059,14 +1292,14 @@ function firefoxProfiles(def, base) {
1059
1292
  const out = [];
1060
1293
  let dirs = [];
1061
1294
  try {
1062
- const ini = readFileSync3(join3(base, "profiles.ini"), "utf8");
1295
+ const ini = readFileSync4(join4(base, "profiles.ini"), "utf8");
1063
1296
  dirs = [...ini.matchAll(/^Path=(.+)$/gm)].map((m) => m[1].trim());
1064
1297
  } catch {
1065
1298
  dirs = safeReaddir(base).filter((d) => /\.default|\.dev-edition/.test(d));
1066
1299
  }
1067
1300
  for (const rel of dirs) {
1068
- const path = join3(base, rel);
1069
- if (existsSync3(join3(path, "cookies.sqlite")))
1301
+ const path = join4(base, rel);
1302
+ if (existsSync4(join4(path, "cookies.sqlite")))
1070
1303
  out.push({ browser: def.name, profile: rel, label: `${def.name} · ${rel}`, path, engine: "firefox" });
1071
1304
  }
1072
1305
  return out;
@@ -1082,7 +1315,7 @@ function readCookies(profile, opts = {}) {
1082
1315
  return profile.engine === "chromium" ? chromiumCookies(profile, opts) : firefoxCookies(profile, opts);
1083
1316
  }
1084
1317
  function loadDb(path) {
1085
- return new Uint8Array(readFileSync3(path));
1318
+ return new Uint8Array(readFileSync4(path));
1086
1319
  }
1087
1320
  function domainMatch(host, want) {
1088
1321
  if (!want)
@@ -1092,7 +1325,7 @@ function domainMatch(host, want) {
1092
1325
  return h === w || h.endsWith(`.${w}`);
1093
1326
  }
1094
1327
  function chromiumCookies(profile, opts) {
1095
- const dbPath = existsSync3(join3(profile.path, "Network", "Cookies")) ? join3(profile.path, "Network", "Cookies") : join3(profile.path, "Cookies");
1328
+ const dbPath = existsSync4(join4(profile.path, "Network", "Cookies")) ? join4(profile.path, "Network", "Cookies") : join4(profile.path, "Cookies");
1096
1329
  const rows = readTable(loadDb(dbPath), "cookies");
1097
1330
  const decryptor = chromiumDecryptor(profile.browser, rows);
1098
1331
  const out = [];
@@ -1118,7 +1351,7 @@ function chromiumCookies(profile, opts) {
1118
1351
  return out;
1119
1352
  }
1120
1353
  function firefoxCookies(profile, opts) {
1121
- const rows = readTable(loadDb(join3(profile.path, "cookies.sqlite")), "moz_cookies");
1354
+ const rows = readTable(loadDb(join4(profile.path, "cookies.sqlite")), "moz_cookies");
1122
1355
  const out = [];
1123
1356
  for (const r of rows) {
1124
1357
  const host = String(r.host ?? "");
@@ -1259,7 +1492,7 @@ function keychainName(browser) {
1259
1492
  function windowsKey(_browser) {
1260
1493
  return null;
1261
1494
  }
1262
- var home = () => process.env.UNBROWSE_COOKIES_HOME ?? homedir3(), OS, BROWSERS, TEXT;
1495
+ var home = () => process.env.UNBROWSE_COOKIES_HOME ?? homedir4(), OS, BROWSERS, TEXT;
1263
1496
  var init_cookies = __esm(() => {
1264
1497
  OS = platform2();
1265
1498
  BROWSERS = [
@@ -1280,7 +1513,7 @@ var init_cookies = __esm(() => {
1280
1513
  // src/cli.ts
1281
1514
  import { spawn, spawnSync } from "node:child_process";
1282
1515
  import { randomUUID } from "node:crypto";
1283
- import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync4, realpathSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
1516
+ import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync5, realpathSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
1284
1517
  import { dirname as dirname3 } from "node:path";
1285
1518
  import { createInterface as createInterface2 } from "node:readline/promises";
1286
1519
 
@@ -1323,6 +1556,34 @@ function v1Key(baseUrl) {
1323
1556
  return;
1324
1557
  }
1325
1558
  }
1559
+ function sharedSignIn(baseUrl, now = Date.now()) {
1560
+ if (process.env.UNBROWSE_SHARED_SIGNIN === "0")
1561
+ return;
1562
+ const dir = process.env.CLAUDE_CONFIG_DIR ?? join(process.env.HOME || homedir(), ".claude");
1563
+ let entries;
1564
+ try {
1565
+ entries = Object.values(JSON.parse(readFileSync(join(dir, ".credentials.json"), "utf8")).mcpOAuth ?? {});
1566
+ } catch {
1567
+ return;
1568
+ }
1569
+ const api = new URL(apiResource(baseUrl));
1570
+ for (const e of entries) {
1571
+ if (typeof e?.serverUrl !== "string" || typeof e.accessToken !== "string" || !e.accessToken)
1572
+ continue;
1573
+ if (typeof e.expiresAt === "number" && now > e.expiresAt - 60000)
1574
+ continue;
1575
+ let url;
1576
+ try {
1577
+ url = new URL(e.serverUrl);
1578
+ } catch {
1579
+ continue;
1580
+ }
1581
+ if (url.origin !== api.origin || !(url.pathname === api.pathname || url.pathname.startsWith(`${api.pathname}/`)))
1582
+ continue;
1583
+ return { token: e.accessToken, from: `Claude Code's MCP sign-in (${String(e.serverName ?? url.origin + url.pathname)})` };
1584
+ }
1585
+ return;
1586
+ }
1326
1587
  async function currentToken(baseUrl, fetchImpl = fetch) {
1327
1588
  if (process.env.UNBROWSE_API_KEY)
1328
1589
  return process.env.UNBROWSE_API_KEY;
@@ -1333,7 +1594,7 @@ async function currentToken(baseUrl, fetchImpl = fetch) {
1333
1594
  return stored.apiKey;
1334
1595
  const o = stored.oauth;
1335
1596
  if (!o)
1336
- return v1Key(baseUrl);
1597
+ return v1Key(baseUrl) ?? sharedSignIn(baseUrl)?.token;
1337
1598
  if (o.expiresAt && o.refreshToken && Date.now() > o.expiresAt - 60000) {
1338
1599
  try {
1339
1600
  const fresh = await tokenRequest(baseUrl, { grant_type: "refresh_token", refresh_token: o.refreshToken, client_id: o.clientId, resource: apiResource(baseUrl) }, fetchImpl);
@@ -1345,6 +1606,18 @@ async function currentToken(baseUrl, fetchImpl = fetch) {
1345
1606
  }
1346
1607
  return o.accessToken;
1347
1608
  }
1609
+ function signInSource(baseUrl) {
1610
+ if (process.env.UNBROWSE_API_KEY)
1611
+ return "env";
1612
+ const stored = load();
1613
+ if (stored.baseUrl && stored.baseUrl !== baseUrl)
1614
+ return;
1615
+ if (stored.apiKey || stored.oauth)
1616
+ return "stored";
1617
+ if (v1Key(baseUrl))
1618
+ return "v1";
1619
+ return sharedSignIn(baseUrl) ? "shared" : undefined;
1620
+ }
1348
1621
  function describe(baseUrl) {
1349
1622
  if (process.env.UNBROWSE_API_KEY)
1350
1623
  return "API key from UNBROWSE_API_KEY";
@@ -1358,6 +1631,9 @@ function describe(baseUrl) {
1358
1631
  const v1 = !stored.baseUrl ? v1Key(baseUrl) : undefined;
1359
1632
  if (v1)
1360
1633
  return `v1 API key ${mask(v1)} from ~/.unbrowse/config.json (still works; \`unbrowse login\` replaces it)`;
1634
+ const shared = !stored.baseUrl ? sharedSignIn(baseUrl) : undefined;
1635
+ if (shared)
1636
+ return `${shared.from}, shared (\`unbrowse login\` gives the CLI its own)`;
1361
1637
  return "not signed in";
1362
1638
  }
1363
1639
  function mask(key) {
@@ -1442,7 +1718,7 @@ ${url.toString()}`);
1442
1718
 
1443
1719
  // src/cli.ts
1444
1720
  init_src();
1445
- var VERSION = "12.2.1-preview.0";
1721
+ var VERSION = "12.3.0";
1446
1722
  var DEFAULT_ORIGIN = "https://unbrowse.ai";
1447
1723
  var HELP = `unbrowse ${VERSION} — call websites as APIs through the Unbrowse API
1448
1724
 
@@ -1474,6 +1750,10 @@ var HELP = `unbrowse ${VERSION} — call websites as APIs through the Unbrowse A
1474
1750
  cookies daemon start|status|stop
1475
1751
  The same as a background service (systemd / launchd) [--domain a,b | --all-sites] [--yes]
1476
1752
 
1753
+ mcps MCP servers in this machine's agents (Claude Code, Cursor, Codex, VS Code, …)
1754
+ mcps import [name…] Bring them into Unbrowse: remote ones by address, local ones as their hosted app
1755
+ [--dry-run] [--with-keys] keys in those configs stay here unless --with-keys (asks first; --yes in scripts)
1756
+
1477
1757
  learn <a.har> <b.har>… Compile two HAR recordings into a capability [--title T] [--goal G]
1478
1758
  learned [id] Your learned capabilities, or one with its harness and skill
1479
1759
  logins Saved logins as masked hints
@@ -1487,11 +1767,15 @@ var HELP = `unbrowse ${VERSION} — call websites as APIs through the Unbrowse A
1487
1767
 
1488
1768
  mcp Local stdio MCP server proxying the hosted one (tool names use _ not .)
1489
1769
  [--url URL] [--end-user ID] for agent hosts that need stdio or strict tool names (Grok Build)
1770
+ [--from-here] unbrowse_run sends its site requests from this machine, like run (a Grok Bot's computer)
1771
+ mcp add [claude] [codex] Register that proxy with Claude Code / Codex: they use this CLI's sign-in (no second sign-in)
1772
+ [--name NAME] [--replace] [--dry-run]
1773
+ And back: no CLI sign-in, but Claude Code signed in to Unbrowse (plugin) → the CLI uses that
1490
1774
 
1491
1775
  Options: --json (errors as JSON) · --base-url URL · --no-open
1492
1776
  Env: UNBROWSE_API_KEY, UNBROWSE_BASE_URL (default ${DEFAULT_ORIGIN}), UNBROWSE_EGRESS=server, UNBROWSE_MCP_URL, UNBROWSE_END_USER
1493
1777
  Exit: 0 ok · 1 error · 2 input required · 3 sign-in or login needed · 4 not verified`;
1494
- var BOOLEAN = new Set(["json", "help", "version", "no-open", "no-wait", "unattended", "from-here", "from-unbrowse", "all", "all-sites", "yes", "full", "events"]);
1778
+ var BOOLEAN = new Set(["json", "help", "version", "no-open", "no-wait", "unattended", "from-here", "from-unbrowse", "all", "all-sites", "yes", "full", "events", "dry-run", "with-keys", "replace"]);
1495
1779
  var NEEDS_ACCOUNT = new Set(["whoami", "usage", "discover", "run", "scrape", "index", "inspect", "resume", "cancel", "learn", "learned", "logins", "call", "cookies"]);
1496
1780
  var SCRAPE_FORMATS = ["markdown", "html", "text", "links", "raw"];
1497
1781
  function parseArgs(argv) {
@@ -1553,14 +1837,15 @@ async function main(argv, io = stdio) {
1553
1837
  const origin = String(args.flags["base-url"] ?? process.env.UNBROWSE_BASE_URL ?? load().baseUrl ?? DEFAULT_ORIGIN).replace(/\/+$/, "");
1554
1838
  const flag = (k) => typeof args.flags[k] === "string" ? args.flags[k] : undefined;
1555
1839
  const print = (v) => io.out(JSON.stringify(v, null, 2));
1556
- const client = async () => new Unbrowse({ apiKey: await currentToken(origin) ?? "", baseUrl: origin });
1557
- if (cmd === "mcp") {
1840
+ const client = async () => new Unbrowse({ apiKey: await currentToken(origin) ?? "", baseUrl: origin, client: `cli/${VERSION}` });
1841
+ if (cmd === "mcp" && rest[0] !== "add") {
1558
1842
  await Promise.resolve().then(() => init_mcp_proxy());
1559
1843
  await serveStdio({
1560
1844
  url: flag("url") ?? process.env.UNBROWSE_MCP_URL ?? `${origin}/api/mcp`,
1561
1845
  token: async () => await currentToken(origin) ?? undefined,
1562
1846
  version: VERSION,
1563
- endUser: flag("end-user") ?? process.env.UNBROWSE_END_USER
1847
+ endUser: flag("end-user") ?? process.env.UNBROWSE_END_USER,
1848
+ ...args.flags["from-here"] || process.env.UNBROWSE_EGRESS === "client" ? { fromHere: client } : {}
1564
1849
  });
1565
1850
  return 0;
1566
1851
  }
@@ -1636,7 +1921,7 @@ async function main(argv, io = stdio) {
1636
1921
  if (!io.interactive || !io.ask || args.flags.json)
1637
1922
  return;
1638
1923
  const { D, dir } = await cookieSync();
1639
- if (existsSync4(D.configFile(dir)))
1924
+ if (existsSync5(D.configFile(dir)))
1640
1925
  return;
1641
1926
  io.err("Optional: let runs act as you on sites you are signed into in your browser. Unbrowse keeps those sites' cookies in sync in the background (sealed in your vault; only the sites you name leave this machine).");
1642
1927
  const answer = (await io.ask("Sites to keep in sync (e.g. github.com, linkedin.com), or Enter to skip: ")).trim();
@@ -1678,6 +1963,8 @@ Your browser opens ${origin}; approve it and \`unbrowse ${cmd}\` carries on. (Sc
1678
1963
  await offerCookieSync().catch((e) => io.err(`Cookie sync not set up: ${e.message}`));
1679
1964
  return 0;
1680
1965
  }
1966
+ case "mcp":
1967
+ return await mcpAdd(rest.slice(1), args, io, origin);
1681
1968
  case "logout":
1682
1969
  clear();
1683
1970
  io.err("Signed out.");
@@ -1829,7 +2116,7 @@ Your browser opens ${origin}; approve it and \`unbrowse ${cmd}\` carries on. (Sc
1829
2116
  if (plan)
1830
2117
  for (const cmd of plan.disable)
1831
2118
  exec(cmd);
1832
- if (plan && existsSync4(plan.path))
2119
+ if (plan && existsSync5(plan.path))
1833
2120
  rmSync2(plan.path, { force: true });
1834
2121
  rmSync2(D.configFile(dir), { force: true });
1835
2122
  rmSync2(D.stateFile(dir), { force: true });
@@ -1838,7 +2125,7 @@ Your browser opens ${origin}; approve it and \`unbrowse ${cmd}\` carries on. (Sc
1838
2125
  }
1839
2126
  if (action === "status") {
1840
2127
  const state = D.readJson(D.stateFile(dir), { sites: {} });
1841
- const installed = !!plan && existsSync4(plan.path);
2128
+ const installed = !!plan && existsSync5(plan.path);
1842
2129
  const active = installed && plan ? exec(plan.status).status === 0 : false;
1843
2130
  const out = {
1844
2131
  installed,
@@ -1918,13 +2205,15 @@ Your browser opens ${origin}; approve it and \`unbrowse ${cmd}\` carries on. (Sc
1918
2205
  }
1919
2206
  case "learn": {
1920
2207
  need(rest[0], "learn <a.har> <b.har>…");
1921
- const har = rest.map((f) => JSON.parse(readFileSync4(f, "utf8")));
2208
+ const har = rest.map((f) => JSON.parse(readFileSync5(f, "utf8")));
1922
2209
  print(await ub.learn({ har: har.length === 1 ? har[0] : har, ...flag("title") ? { title: flag("title") } : {}, ...flag("goal") ? { goal: flag("goal") } : {} }));
1923
2210
  return 0;
1924
2211
  }
1925
2212
  case "learned":
1926
2213
  print(await ub.learned(rest[0]));
1927
2214
  return 0;
2215
+ case "mcps":
2216
+ return await mcps(rest, args, io, origin, print, client, onboard);
1928
2217
  case "logins":
1929
2218
  if (rest[0] === "remove")
1930
2219
  print(await ub.logins.remove({ origin: need(rest[1], "logins remove <origin>") }));
@@ -2018,6 +2307,148 @@ async function settle(ub, view, args, io, print) {
2018
2307
  io.err(`${view.status}: ${view.error.code}${view.error.message ? ` — ${view.error.message}` : ""}`);
2019
2308
  return 1;
2020
2309
  }
2310
+ async function mcpAdd(rest, args, io, origin) {
2311
+ const HOSTS = ["claude", "codex"];
2312
+ const unknown = rest.filter((h) => !HOSTS.includes(h));
2313
+ if (unknown.length)
2314
+ throw new UsageError(`usage: unbrowse mcp add [claude] [codex] [--name NAME] [--replace] [--dry-run] (unknown: ${unknown.join(", ")})`);
2315
+ const name = (typeof args.flags.name === "string" ? args.flags.name : "") || "unbrowse";
2316
+ const exec = io.exec ?? (() => ({ status: 1, stdout: "", stderr: "no exec" }));
2317
+ const dry = !!args.flags["dry-run"];
2318
+ const replace = !!args.flags.replace;
2319
+ const win = (io.service?.os ?? process.platform) === "win32";
2320
+ const shell = (cmd) => win ? ["cmd", "/c", ...cmd] : cmd;
2321
+ await Promise.resolve().then(() => init_cookie_daemon());
2322
+ let cli = io.service?.cli ?? process.argv[1] ?? "";
2323
+ try {
2324
+ cli = realpathSync(cli);
2325
+ } catch {}
2326
+ const launch = [...io.service?.cli || !ephemeralCli(cli) ? [io.service?.node ?? process.execPath, cli] : shell(["npx", "-y", "unbrowse"]), "mcp", ...origin !== DEFAULT_ORIGIN ? ["--base-url", origin] : []];
2327
+ const env = ["UNBROWSE_CONFIG_DIR", "XDG_CONFIG_HOME", "CLAUDE_CONFIG_DIR", "UNBROWSE_SHARED_SIGNIN"].flatMap((k) => process.env[k] ? [`${k}=${process.env[k]}`] : []);
2328
+ const plans = {
2329
+ claude: { get: ["claude", "mcp", "get", name], remove: ["claude", "mcp", "remove", name, "--scope", "user"], add: ["claude", "mcp", "add", "--scope", "user", name, ...env.flatMap((e) => ["-e", e]), "--", ...launch] },
2330
+ codex: { get: ["codex", "mcp", "get", name], remove: ["codex", "mcp", "remove", name], add: ["codex", "mcp", "add", name, ...env.flatMap((e) => ["--env", e]), "--", ...launch] }
2331
+ };
2332
+ const wanted = rest.length ? rest : HOSTS.filter((h) => dry || exec(shell([h, "--version"])).status === 0);
2333
+ if (!wanted.length)
2334
+ return io.err("No Claude Code or Codex found on PATH. Any MCP host can run the proxy itself: " + launch.join(" ")), 1;
2335
+ let failed = 0;
2336
+ for (const h of wanted) {
2337
+ const plan = plans[h];
2338
+ if (dry) {
2339
+ io.out([...replace ? [plan.remove] : [], plan.add].map((c) => shell(c).join(" ")).join(`
2340
+ `));
2341
+ continue;
2342
+ }
2343
+ if (exec(shell(plan.get)).status === 0) {
2344
+ if (!replace) {
2345
+ failed++;
2346
+ io.err(`✗ ${h}: a server named "${name}" already exists; left as it is. Re-run with --replace, or pick --name.`);
2347
+ continue;
2348
+ }
2349
+ const r = exec(shell(plan.remove));
2350
+ if (r.status !== 0) {
2351
+ failed++;
2352
+ io.err(`✗ ${h}: could not remove the existing "${name}": ${(r.stderr || r.stdout).trim() || `exit ${r.status}`}`);
2353
+ continue;
2354
+ }
2355
+ }
2356
+ const r = exec(shell(plan.add));
2357
+ if (r.status === 0)
2358
+ io.err(`✓ ${h}: added "${name}" (stdio, on this CLI's sign-in)`);
2359
+ else
2360
+ failed++, io.err(`✗ ${h}: ${(r.stderr || r.stdout).trim() || `exit ${r.status}`}`);
2361
+ }
2362
+ if (!dry) {
2363
+ const source = signInSource(origin);
2364
+ if (source === "env")
2365
+ io.err("This CLI is signed in only through UNBROWSE_API_KEY, which the hosts do not get: run `unbrowse login` (or `unbrowse login --key`) so they have a sign-in.");
2366
+ else if (source === "shared")
2367
+ io.err("This CLI is using Claude Code's sign-in, which only Claude Code renews: run `unbrowse login` so the hosts keep working.");
2368
+ else if (!source)
2369
+ io.err("Not signed in yet: run `unbrowse login` once and the hosts use that sign-in.");
2370
+ }
2371
+ return failed ? 1 : 0;
2372
+ }
2373
+ async function mcps(rest, args, io, origin, print, client, onboard) {
2374
+ const A = await Promise.resolve().then(() => (init_agent_mcps(), {}));
2375
+ const { servers, skipped } = scan();
2376
+ const tilde = (f) => f.replace(process.env.HOME ?? "\x00", "~");
2377
+ if (rest[0] !== "import") {
2378
+ if (rest[0])
2379
+ throw new UsageError("usage: unbrowse mcps [import [name…] [--dry-run] [--with-keys]]");
2380
+ if (args.flags.json)
2381
+ return print({ servers: servers.map(({ file, ...s }) => ({ ...s, file: tilde(file) })), skipped }), 0;
2382
+ if (!servers.length)
2383
+ return io.err(`No MCP servers found in this machine's agent configs.${skipped.length ? ` Unreadable: ${skipped.map((x) => tilde(x.file)).join(", ")}` : ""}`), 0;
2384
+ let last = "";
2385
+ for (const s of servers) {
2386
+ const where = `${s.host} ${tilde(s.file)}`;
2387
+ if (where !== last)
2388
+ io.out(where), last = where;
2389
+ const what = s.url ? `remote ${s.url}` : `local ${[s.command, ...s.args ?? []].join(" ").slice(0, 90)}`;
2390
+ const env = s.envNames.length > 3 ? [...s.envNames.slice(0, 3).map((e) => `env ${e}`), `+${s.envNames.length - 3} env`] : s.envNames.map((e) => `env ${e}`);
2391
+ const secrets = [...s.headerNames.map((h) => `header ${h}`), ...env];
2392
+ io.out(` ${s.name.padEnd(22)} ${what}${secrets.length ? ` (${secrets.join(", ")})` : ""}${s.self ? " (Unbrowse itself)" : ""}`);
2393
+ }
2394
+ io.err(`
2395
+ ${unique(servers.filter((s) => !s.self)).length} to import: unbrowse mcps import --dry-run`);
2396
+ return 0;
2397
+ }
2398
+ if (!await currentToken(origin) && !await onboard())
2399
+ return 3;
2400
+ const names = new Set(rest.slice(1));
2401
+ const pick = unique(servers.filter((s) => !s.self && (!names.size || names.has(s.name))));
2402
+ const missing = [...names].filter((n) => !servers.some((s) => s.name === n));
2403
+ if (missing.length)
2404
+ throw new UsageError(`no MCP server named ${missing.join(", ")} in this machine's agent configs (unbrowse mcps lists them)`);
2405
+ if (!pick.length)
2406
+ return io.err("Nothing to import."), 0;
2407
+ const dry = !!args.flags["dry-run"];
2408
+ const keyed = args.flags["with-keys"] ? pick.flatMap((f) => {
2409
+ const k = keyOf(f, rawEntry(f) ?? {});
2410
+ return k ? [{ f, k }] : [];
2411
+ }) : [];
2412
+ if (keyed.length && !dry && !args.flags.yes) {
2413
+ const list = keyed.map((x) => `${x.f.name} (${x.k.header})`).join(", ");
2414
+ if (!io.interactive || !io.ask)
2415
+ throw new UsageError(`Re-run with --yes to confirm: the keys for ${list} will be sent to ${origin} and sealed in your vault.`);
2416
+ const ok = (await io.ask(`Send the keys for ${list} from your agent configs to Unbrowse (sealed in your vault)? [y/N] `)).trim().toLowerCase();
2417
+ if (ok !== "y" && ok !== "yes")
2418
+ return io.err("Nothing sent."), 1;
2419
+ }
2420
+ const ub = await client();
2421
+ const rows = [];
2422
+ for (const { f, k } of keyed) {
2423
+ if (dry) {
2424
+ rows.push({ name: f.name, status: "planned", via: "url", note: `Would connect ${f.url} with the ${k.header} from its config.` });
2425
+ continue;
2426
+ }
2427
+ try {
2428
+ const r = await ub.connectors.connect({ url: f.url, name: f.name, ...f.transport ? { transport: f.transport } : {}, header: k.header, key: k.key });
2429
+ rows.push({ name: f.name, status: r.status, app: r.app, tools: r.tools, url: r.url, via: "url", note: r.status === "connected" ? `Connected with its key: ${r.tools} tools.` : `Needs the person: ${r.url}` });
2430
+ } catch (e) {
2431
+ rows.push({ name: f.name, status: "failed", note: e.message.slice(0, 200) });
2432
+ }
2433
+ }
2434
+ const rest2 = pick.filter((f) => !keyed.some((x) => x.f === f));
2435
+ if (rest2.length)
2436
+ rows.push(...(await ub.connectors.import(rest2.map(toImport), { dryRun: dry })).results);
2437
+ if (args.flags.json)
2438
+ print({ results: rows });
2439
+ else {
2440
+ const mark = { connected: "✓", planned: "·", sign_in: "→", key: "→", already: "=", skipped: "-", local_only: "-", failed: "✗" };
2441
+ for (const r of rows)
2442
+ io.out(`${mark[r.status] ?? "?"} ${r.name.padEnd(22)} ${r.status.padEnd(10)} ${r.note}${r.url ? `
2443
+ ${r.url}` : ""}`);
2444
+ const hasKeyRows = rows.some((r) => r.status === "key") && !args.flags["with-keys"] && pick.some((f) => f.headerNames.length);
2445
+ if (hasKeyRows)
2446
+ io.err("A key server can take the key from your agent config instead: unbrowse mcps import --with-keys");
2447
+ if (dry)
2448
+ io.err("Nothing was connected. Run without --dry-run to import.");
2449
+ }
2450
+ return rows.some((r) => r.status === "failed") ? 1 : 0;
2451
+ }
2021
2452
  function noCapabilityHint(view, args) {
2022
2453
  const result = view.result ?? {};
2023
2454
  const task = args._.slice(1).join(" ");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbrowse",
3
- "version": "12.2.1-preview.0",
3
+ "version": "12.3.0",
4
4
  "mcpName": "io.github.unbrowse-ai/unbrowse",
5
5
  "description": "Call websites as APIs: a thin CLI over the Unbrowse API.",
6
6
  "license": "MIT",
@@ -549,6 +549,65 @@
549
549
  "additionalProperties": false
550
550
  }
551
551
  },
552
+ {
553
+ "name": "unbrowse_import_mcps",
554
+ "description": "Import the MCP servers this agent already has into Unbrowse, so their tools run here with the person's own account and every agent signed in to Unbrowse gets them. Read your host's MCP config yourself (Claude Code: ~/.claude.json and .mcp.json; Claude Desktop: claude_desktop_config.json; Cursor: ~/.cursor/mcp.json; Codex: ~/.codex/config.toml; VS Code: .vscode/mcp.json; Windsurf: ~/.codeium/windsurf/mcp_config.json; Gemini: ~/.gemini/settings.json) and pass each server: a remote one as {name, url}, a local one as {name, command, args}. Never pass keys, headers or env values. A remote server is connected by its address (open ones at once; one with OAuth or a key returns the link the person opens). A local server cannot run in Unbrowse's cloud: when the catalog has the same app hosted, that is connected instead, otherwise it is reported local_only and stays in the agent. Servers already connected, duplicates and Unbrowse itself are skipped. dryRun: true shows the plan without connecting. CLI: `unbrowse mcps import` reads the configs itself.",
555
+ "inputSchema": {
556
+ "type": "object",
557
+ "properties": {
558
+ "servers": {
559
+ "type": "array",
560
+ "maxItems": 50,
561
+ "description": "The MCP servers in your agent's config",
562
+ "items": {
563
+ "type": "object",
564
+ "properties": {
565
+ "name": {
566
+ "type": "string",
567
+ "description": "The server's name in the config"
568
+ },
569
+ "url": {
570
+ "type": "string",
571
+ "description": "A remote server's https address"
572
+ },
573
+ "transport": {
574
+ "type": "string",
575
+ "enum": [
576
+ "streamable-http",
577
+ "http",
578
+ "sse"
579
+ ],
580
+ "description": "A remote server's transport, when the config says"
581
+ },
582
+ "command": {
583
+ "type": "string",
584
+ "description": "A local server's command, e.g. npx"
585
+ },
586
+ "args": {
587
+ "type": "array",
588
+ "items": {
589
+ "type": "string"
590
+ },
591
+ "description": "A local server's arguments (package names; leave out tokens and paths to secrets)"
592
+ }
593
+ },
594
+ "required": [
595
+ "name"
596
+ ],
597
+ "additionalProperties": false
598
+ }
599
+ },
600
+ "dryRun": {
601
+ "type": "boolean",
602
+ "description": "Show what would happen without connecting anything"
603
+ }
604
+ },
605
+ "required": [
606
+ "servers"
607
+ ],
608
+ "additionalProperties": false
609
+ }
610
+ },
552
611
  {
553
612
  "name": "unbrowse_credits",
554
613
  "description": "This workspace's credits: the free monthly allowance left (pays for Unbrowse's own verified calls only; resets monthly) and paid credits (bought by the user; never expire), plus recent credit history and the top-up link. 1 credit = one verified call = $0.001. Pass a pack (p10 = $10 → 10,000, p50 = $50 → 50,000, p200 = $200 → 200,000) to get a Stripe Checkout link for the user to open — you never pay or see card details.",