unbrowse 12.0.1 → 12.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.
Files changed (3) hide show
  1. package/SKILL.md +11 -0
  2. package/dist/cli.js +424 -165
  3. package/package.json +2 -1
package/SKILL.md CHANGED
@@ -25,6 +25,17 @@ CLI installation and SDK instructions: https://github.com/unbrowse-ai/unbrowse-s
25
25
  Use the client version identified there; older npm versions may target a different service.
26
26
  For automation, supply an API key via `UNBROWSE_API_KEY` using the caller's secret manager. Do not put keys into committed config, prompts, command arguments or logs. Create a key in the signed-in console at https://unbrowse.ai/app.
27
27
 
28
+ ## Host plugins
29
+
30
+ Packaged installs that bundle this skill, connect Unbrowse and redirect the host's own browser to it (https://github.com/unbrowse-ai/unbrowse/tree/main/plugins):
31
+
32
+ - Claude Code: `claude plugin marketplace add unbrowse-ai/unbrowse`, then `claude plugin install unbrowse@unbrowse`.
33
+ - Codex: `codex plugin marketplace add unbrowse-ai/unbrowse`, then `codex plugin add unbrowse@unbrowse`; set `web_search = "disabled"` to drop built-in search.
34
+ - Grok Build: `grok plugin install unbrowse-ai/unbrowse#plugins/grok-build --trust`.
35
+ - OpenClaw (`@unbrowse/openclaw`), Hermes (`plugins/hermes`), elizaOS (`@unbrowse/plugin-unbrowse`): native tools or actions over the same MCP.
36
+
37
+ Hosts that need a stdio server or reject dotted tool names can run `npx unbrowse mcp`: tool names there use `_` (`unbrowse_scrape`). With a plugin installed, built-in web fetch, web search and browser navigation to non-local URLs are refused with a pointer to the matching Unbrowse tool; `UNBROWSE_ALLOW_BUILTIN_BROWSER=1` lifts that for a session.
38
+
28
39
  ## Choose the interface
29
40
 
30
41
  - MCP: discovery, runs, page reading, cloud browsing, saved-login requests and live canvas cards.
package/dist/cli.js CHANGED
@@ -1,169 +1,17 @@
1
1
  #!/usr/bin/env node
2
-
3
- // src/cli.ts
4
- import { spawn } from "node:child_process";
5
- import { randomUUID } from "node:crypto";
6
- import { readFileSync as readFileSync2 } from "node:fs";
7
-
8
- // src/auth.ts
9
- import { createHash, randomBytes } from "node:crypto";
10
- import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11
- import { createServer } from "node:http";
12
- import { homedir } from "node:os";
13
- import { dirname, join } from "node:path";
14
- function configPath() {
15
- const root = process.env.UNBROWSE_CONFIG_DIR ?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "unbrowse");
16
- return join(root, "cli.json");
17
- }
18
- function load() {
19
- const path = configPath();
20
- if (!existsSync(path))
21
- return {};
22
- try {
23
- return JSON.parse(readFileSync(path, "utf8"));
24
- } catch {
25
- return {};
26
- }
27
- }
28
- function save(stored) {
29
- const path = configPath();
30
- mkdirSync(dirname(path), { recursive: true, mode: 448 });
31
- writeFileSync(path, JSON.stringify(stored, null, 2), { mode: 384 });
32
- chmodSync(path, 384);
33
- }
34
- function clear() {
35
- rmSync(configPath(), { force: true });
36
- }
37
- async function currentToken(baseUrl, fetchImpl = fetch) {
38
- if (process.env.UNBROWSE_API_KEY)
39
- return process.env.UNBROWSE_API_KEY;
40
- const stored = load();
41
- if (stored.baseUrl && stored.baseUrl !== baseUrl)
42
- return;
43
- if (stored.apiKey)
44
- return stored.apiKey;
45
- const o = stored.oauth;
46
- if (!o)
47
- return;
48
- if (o.expiresAt && o.refreshToken && Date.now() > o.expiresAt - 60000) {
2
+ var __esm = (fn, res, err) => () => {
3
+ if (fn)
49
4
  try {
50
- const fresh = await tokenRequest(baseUrl, { grant_type: "refresh_token", refresh_token: o.refreshToken, client_id: o.clientId, resource: apiResource(baseUrl) }, fetchImpl);
51
- save({ ...stored, oauth: { clientId: o.clientId, ...fresh, refreshToken: fresh.refreshToken ?? o.refreshToken } });
52
- return fresh.accessToken;
53
- } catch {
54
- return o.accessToken;
5
+ res = fn(fn = 0);
6
+ } catch (e) {
7
+ err = [e];
55
8
  }
56
- }
57
- return o.accessToken;
58
- }
59
- function describe(baseUrl) {
60
- if (process.env.UNBROWSE_API_KEY)
61
- return "API key from UNBROWSE_API_KEY";
62
- const stored = load();
63
- if (stored.baseUrl && stored.baseUrl !== baseUrl)
64
- return `not signed in to ${baseUrl} (stored login is for ${stored.baseUrl})`;
65
- if (stored.apiKey)
66
- return `API key ${mask(stored.apiKey)} in ${configPath()}`;
67
- if (stored.oauth)
68
- return `OAuth sign-in in ${configPath()}`;
69
- return "not signed in";
70
- }
71
- function mask(key) {
72
- return key.length <= 12 ? "…" : `${key.slice(0, 8)}…${key.slice(-4)}`;
73
- }
74
- function apiResource(baseUrl) {
75
- return `${baseUrl.replace(/\/+$/, "")}/api`;
76
- }
77
- async function tokenRequest(baseUrl, form, fetchImpl) {
78
- const res = await fetchImpl(`${baseUrl}/oauth/token`, {
79
- method: "POST",
80
- headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
81
- body: new URLSearchParams(form).toString()
82
- });
83
- const body = await res.json().catch(() => ({}));
84
- if (!res.ok || !body.access_token)
85
- throw new Error(body.error_description ?? body.error ?? `token endpoint answered HTTP ${res.status}`);
86
- return { accessToken: body.access_token, refreshToken: body.refresh_token, expiresAt: body.expires_in ? Date.now() + body.expires_in * 1000 : undefined };
87
- }
88
- var b64url = (buf) => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
89
- async function oauthLogin(baseUrl, opts) {
90
- const fetchImpl = opts.fetch ?? fetch;
91
- const verifier = b64url(randomBytes(32));
92
- const challenge = b64url(createHash("sha256").update(verifier).digest());
93
- const state = b64url(randomBytes(16));
94
- let settle;
95
- const got = new Promise((r) => settle = r);
96
- const server = createServer((req, res) => {
97
- const u = new URL(req.url ?? "/", "http://127.0.0.1");
98
- if (u.pathname !== "/callback") {
99
- res.writeHead(404).end();
100
- return;
101
- }
102
- const ok = u.searchParams.get("state") === state && !!u.searchParams.get("code");
103
- res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
104
- res.end(ok ? "<p>Signed in to Unbrowse. You can close this tab.</p>" : "<p>Sign-in failed. Return to the terminal.</p>");
105
- settle(ok ? { code: u.searchParams.get("code") } : { error: u.searchParams.get("error_description") ?? u.searchParams.get("error") ?? "state mismatch" });
106
- });
107
- await new Promise((r) => server.listen(0, "127.0.0.1", r));
108
- const port = server.address().port;
109
- const redirectUri = `http://127.0.0.1:${port}/callback`;
110
- try {
111
- const reg = await fetchImpl(`${baseUrl}/oauth/register`, {
112
- method: "POST",
113
- headers: { "content-type": "application/json", accept: "application/json" },
114
- body: JSON.stringify({
115
- client_name: "Unbrowse CLI",
116
- redirect_uris: [redirectUri],
117
- grant_types: ["authorization_code", "refresh_token"],
118
- response_types: ["code"],
119
- token_endpoint_auth_method: "none"
120
- })
121
- });
122
- const client = await reg.json().catch(() => ({}));
123
- if (!reg.ok || !client.client_id)
124
- throw new Error(`client registration failed: ${client.error_description ?? `HTTP ${reg.status}`}`);
125
- const url = new URL(`${baseUrl}/authorize`);
126
- url.search = new URLSearchParams({
127
- response_type: "code",
128
- client_id: client.client_id,
129
- redirect_uri: redirectUri,
130
- code_challenge: challenge,
131
- code_challenge_method: "S256",
132
- state,
133
- scope: "unbrowse",
134
- resource: apiResource(baseUrl)
135
- }).toString();
136
- opts.log(`Opening ${url.origin}/authorize in your browser. If it does not open, visit:
137
- ${url.toString()}`);
138
- opts.open(url.toString());
139
- const timer = setTimeout(() => settle({ error: "timed out waiting for the browser sign-in" }), opts.timeoutMs ?? 300000);
140
- const answer = await got;
141
- clearTimeout(timer);
142
- if (!answer.code)
143
- throw new Error(answer.error ?? "sign-in failed");
144
- const tokens = await tokenRequest(baseUrl, { grant_type: "authorization_code", code: answer.code, redirect_uri: redirectUri, client_id: client.client_id, code_verifier: verifier, resource: apiResource(baseUrl) }, fetchImpl);
145
- save({ baseUrl, oauth: { clientId: client.client_id, ...tokens } });
146
- } finally {
147
- server.close();
148
- }
149
- }
9
+ if (err)
10
+ throw err[0];
11
+ return res;
12
+ };
150
13
 
151
14
  // ../sdk/src/client.ts
152
- var DEFAULT_BASE_URL = "https://unbrowse.ai/api/v1";
153
-
154
- class UnbrowseError extends Error {
155
- status;
156
- code;
157
- body;
158
- constructor(message, status, code, body) {
159
- super(message);
160
- this.status = status;
161
- this.code = code;
162
- this.body = body;
163
- this.name = "UnbrowseError";
164
- }
165
- }
166
- var env = (name) => (typeof process !== "undefined" ? process.env?.[name] : undefined) || undefined;
167
15
  function apiBase(url) {
168
16
  const u = url.replace(/\/+$/, "");
169
17
  return /\/api\/v1$/.test(u) ? u : `${u}/api/v1`;
@@ -250,6 +98,43 @@ class Unbrowse {
250
98
  cancel(runId) {
251
99
  return this.req(`/runs/${runId}/cancel`, { method: "POST" });
252
100
  }
101
+ async runOnClient(request, opts = {}) {
102
+ const send = opts.fetch ?? ((...a) => globalThis.fetch(...a));
103
+ const deadline = Date.now() + (opts.timeoutMs ?? 600000);
104
+ let step = await this.run({ ...request, egress: "client" });
105
+ while (isEgressStep(step)) {
106
+ if (Date.now() > deadline) {
107
+ await this.closeEgress(step.egressId).catch(() => {
108
+ return;
109
+ });
110
+ throw new UnbrowseError("client-egress run did not finish in time", 408, "timeout");
111
+ }
112
+ const { egressId } = step;
113
+ for (const req of step.requests) {
114
+ let answer;
115
+ try {
116
+ if (await opts.onRequest?.(req) === false)
117
+ throw new Error("refused by onRequest");
118
+ answer = { response: await sendEgress(send, req) };
119
+ } catch (err) {
120
+ answer = { error: err instanceof Error ? err.message : String(err) };
121
+ }
122
+ step = await this.answerEgress(egressId, req.id, answer);
123
+ if (!isEgressStep(step))
124
+ break;
125
+ }
126
+ }
127
+ return step;
128
+ }
129
+ egress(egressId) {
130
+ return this.req(`/egress/${egressId}`);
131
+ }
132
+ answerEgress(egressId, requestId, answer) {
133
+ return this.post(`/egress/${egressId}`, { requestId, ...answer });
134
+ }
135
+ closeEgress(egressId) {
136
+ return this.req(`/egress/${egressId}`, { method: "DELETE" });
137
+ }
253
138
  discover(query) {
254
139
  return this.post("/capabilities/search", { query });
255
140
  }
@@ -311,8 +196,367 @@ function normalizeHost(site) {
311
196
  host = new URL(host).hostname;
312
197
  return host.replace(/\/.*$/, "").replace(/^www\./, "");
313
198
  }
199
+ function isEgressStep(v) {
200
+ return !!v && typeof v === "object" && v.status === "egress_required";
201
+ }
202
+ async function sendEgress(send, req) {
203
+ const body = req.body === undefined ? undefined : req.bodyEncoding === "base64" ? base64ToBytes(req.body) : req.body;
204
+ const res = await send(req.url, { method: req.method, headers: req.headers, redirect: req.redirect, ...body !== undefined ? { body } : {} });
205
+ const headers = [];
206
+ res.headers.forEach((value, name) => headers.push([name, value]));
207
+ const cookies = res.headers.getSetCookie?.();
208
+ const pairs = cookies?.length ? [...headers.filter(([n]) => n.toLowerCase() !== "set-cookie"), ...cookies.map((c) => ["set-cookie", c])] : headers;
209
+ const bytes = new Uint8Array(await res.arrayBuffer());
210
+ return { status: res.status, headers: pairs, body: bytesToBase64(bytes), bodyEncoding: "base64", ...res.url ? { url: res.url } : {} };
211
+ }
212
+ function base64ToBytes(b64) {
213
+ const bin = atob(b64);
214
+ const out = new Uint8Array(bin.length);
215
+ for (let i = 0;i < bin.length; i++)
216
+ out[i] = bin.charCodeAt(i);
217
+ return out;
218
+ }
219
+ function bytesToBase64(bytes) {
220
+ let bin = "";
221
+ for (let i = 0;i < bytes.length; i += 32768)
222
+ bin += String.fromCharCode(...bytes.subarray(i, i + 32768));
223
+ return btoa(bin);
224
+ }
225
+ var DEFAULT_BASE_URL = "https://unbrowse.ai/api/v1", UnbrowseError, env = (name) => (typeof process !== "undefined" ? process.env?.[name] : undefined) || undefined;
226
+ var init_client = __esm(() => {
227
+ UnbrowseError = class UnbrowseError extends Error {
228
+ status;
229
+ code;
230
+ body;
231
+ constructor(message, status, code, body) {
232
+ super(message);
233
+ this.status = status;
234
+ this.code = code;
235
+ this.body = body;
236
+ this.name = "UnbrowseError";
237
+ }
238
+ };
239
+ });
240
+ // ../sdk/src/mcp.ts
241
+ function parseRpcBody(text) {
242
+ const trimmed = text.trim();
243
+ if (trimmed.startsWith("{"))
244
+ return JSON.parse(trimmed);
245
+ const events = trimmed.split(/\n\n+/).flatMap((block) => {
246
+ const data = block.split(`
247
+ `).filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trimStart()).join(`
248
+ `);
249
+ return data ? [data] : [];
250
+ });
251
+ if (!events.length)
252
+ throw new UnbrowseError("Empty MCP response", 502, "mcp_empty");
253
+ return JSON.parse(events[events.length - 1]);
254
+ }
255
+
256
+ class UnbrowseMcp {
257
+ url;
258
+ apiKey;
259
+ endUser;
260
+ client;
261
+ fetchImpl;
262
+ nextId = 1;
263
+ constructor(opts = {}) {
264
+ this.url = (opts.url ?? env2("UNBROWSE_MCP_URL") ?? DEFAULT_MCP_URL).replace(/\/+$/, "");
265
+ this.apiKey = opts.apiKey ?? env2("UNBROWSE_API_KEY");
266
+ this.endUser = opts.endUser ?? env2("UNBROWSE_END_USER");
267
+ this.client = opts.client ?? "sdk";
268
+ this.fetchImpl = opts.fetch ?? ((...a) => globalThis.fetch(...a));
269
+ }
270
+ async rpc(method, params) {
271
+ const res = await this.fetchImpl(this.url, {
272
+ method: "POST",
273
+ headers: {
274
+ ...this.apiKey ? { authorization: `Bearer ${this.apiKey}` } : {},
275
+ ...this.endUser ? { "x-unbrowse-end-user": this.endUser } : {},
276
+ "content-type": "application/json",
277
+ accept: "application/json, text/event-stream",
278
+ "mcp-protocol-version": MCP_PROTOCOL_VERSION,
279
+ "user-agent": `unbrowse-${this.client}`
280
+ },
281
+ body: JSON.stringify({ jsonrpc: "2.0", id: this.nextId++, method, ...params ? { params } : {} })
282
+ });
283
+ const text = await res.text();
284
+ if (!res.ok) {
285
+ let body = {};
286
+ try {
287
+ body = JSON.parse(text);
288
+ } catch {}
289
+ const err = typeof body.error === "object" ? body.error : undefined;
290
+ throw new UnbrowseError(err?.message ?? body.error_description ?? (res.statusText || `HTTP ${res.status}`), res.status, err?.code ?? (typeof body.error === "string" ? body.error : "http_error"), body);
291
+ }
292
+ const msg = parseRpcBody(text);
293
+ if (msg.error)
294
+ throw new UnbrowseError(msg.error.message, 200, msg.error.data?.code ?? `rpc_${msg.error.code}`, msg.error);
295
+ return msg.result;
296
+ }
297
+ async listTools() {
298
+ const tools = [];
299
+ let cursor;
300
+ do {
301
+ const page = await this.rpc("tools/list", cursor ? { cursor } : undefined);
302
+ tools.push(...page.tools);
303
+ cursor = page.nextCursor;
304
+ } while (cursor);
305
+ return tools;
306
+ }
307
+ callTool(name, args = {}) {
308
+ return this.rpc("tools/call", { name, arguments: args });
309
+ }
310
+ }
311
+ var DEFAULT_MCP_URL = "https://unbrowse.ai/mcp", MCP_PROTOCOL_VERSION = "2025-11-25", env2 = (name) => (typeof process !== "undefined" ? process.env?.[name] : undefined) || undefined;
312
+ var init_mcp = __esm(() => {
313
+ init_client();
314
+ });
315
+
316
+ // ../sdk/src/index.ts
317
+ var init_src = __esm(() => {
318
+ init_client();
319
+ init_mcp();
320
+ });
321
+
322
+ // src/mcp-proxy.ts
323
+ import { createInterface } from "node:readline";
324
+ function safeToolName(name) {
325
+ return name.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64);
326
+ }
327
+ function createProxy(upstream, version) {
328
+ const names = new Map;
329
+ async function list() {
330
+ const tools = await (await upstream()).listTools();
331
+ names.clear();
332
+ const out = [];
333
+ for (const t of tools) {
334
+ const safe = safeToolName(t.name);
335
+ if (names.has(safe))
336
+ continue;
337
+ names.set(safe, t.name);
338
+ out.push({ ...t, name: safe });
339
+ }
340
+ return out;
341
+ }
342
+ async function original(safe) {
343
+ if (!names.has(safe))
344
+ await list();
345
+ return names.get(safe) ?? safe;
346
+ }
347
+ return async function handle(msg) {
348
+ if (msg.id === undefined || msg.id === null)
349
+ return;
350
+ const reply = (result) => ({ jsonrpc: "2.0", id: msg.id, result });
351
+ try {
352
+ switch (msg.method) {
353
+ case "initialize": {
354
+ const asked = String(msg.params?.protocolVersion ?? "");
355
+ return reply({
356
+ protocolVersion: PROTOCOLS.includes(asked) ? asked : PROTOCOLS[0],
357
+ capabilities: { tools: { listChanged: false } },
358
+ serverInfo: { name: "unbrowse", title: "Unbrowse (local proxy to the hosted MCP)", version },
359
+ instructions: "Unbrowse is the browserless browser. Prefer unbrowse_discover then unbrowse_run; unbrowse_scrape reads a page; unbrowse_browse_* drive the cloud browser. input_required is not a failure: answer it with unbrowse_resume."
360
+ });
361
+ }
362
+ case "ping":
363
+ return reply({});
364
+ case "tools/list":
365
+ return reply({ tools: await list() });
366
+ case "tools/call": {
367
+ const name = String(msg.params?.name ?? "");
368
+ return reply(await (await upstream()).rpc("tools/call", { ...msg.params, name: await original(name) }));
369
+ }
370
+ default:
371
+ return reply(await (await upstream()).rpc(String(msg.method), msg.params));
372
+ }
373
+ } catch (e) {
374
+ const err = e;
375
+ const code = err instanceof UnbrowseError ? err.code : "proxy_error";
376
+ const hint = err instanceof UnbrowseError && err.status === 401 ? " Run `unbrowse login` (or set UNBROWSE_API_KEY) and restart this server." : "";
377
+ return { jsonrpc: "2.0", id: msg.id, error: { code: -32000, message: `${err.message}${hint}`, data: { code } } };
378
+ }
379
+ };
380
+ }
381
+ async function serveStdio(opts) {
382
+ const handle = createProxy(async () => new UnbrowseMcp({ url: opts.url, apiKey: await opts.token() ?? "", endUser: opts.endUser, client: `cli-mcp/${opts.version}` }), opts.version);
383
+ const write = (m) => new Promise((resolve) => process.stdout.write(`${JSON.stringify(m)}
384
+ `, () => resolve()));
385
+ const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
386
+ const pending = new Set;
387
+ for await (const line of rl) {
388
+ if (!line.trim())
389
+ continue;
390
+ let msg;
391
+ try {
392
+ msg = JSON.parse(line);
393
+ } catch {
394
+ pending.add(write({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } }));
395
+ continue;
396
+ }
397
+ const p = handle(msg).then((res) => res ? write(res) : undefined);
398
+ pending.add(p);
399
+ p.finally(() => pending.delete(p));
400
+ }
401
+ await Promise.all(pending);
402
+ }
403
+ var PROTOCOLS;
404
+ var init_mcp_proxy = __esm(() => {
405
+ init_src();
406
+ PROTOCOLS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
407
+ });
408
+
409
+ // src/cli.ts
410
+ import { spawn } from "node:child_process";
411
+ import { randomUUID } from "node:crypto";
412
+ import { readFileSync as readFileSync2 } from "node:fs";
413
+
414
+ // src/auth.ts
415
+ import { createHash, randomBytes } from "node:crypto";
416
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
417
+ import { createServer } from "node:http";
418
+ import { homedir } from "node:os";
419
+ import { dirname, join } from "node:path";
420
+ function configPath() {
421
+ const root = process.env.UNBROWSE_CONFIG_DIR ?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "unbrowse");
422
+ return join(root, "cli.json");
423
+ }
424
+ function load() {
425
+ const path = configPath();
426
+ if (!existsSync(path))
427
+ return {};
428
+ try {
429
+ return JSON.parse(readFileSync(path, "utf8"));
430
+ } catch {
431
+ return {};
432
+ }
433
+ }
434
+ function save(stored) {
435
+ const path = configPath();
436
+ mkdirSync(dirname(path), { recursive: true, mode: 448 });
437
+ writeFileSync(path, JSON.stringify(stored, null, 2), { mode: 384 });
438
+ chmodSync(path, 384);
439
+ }
440
+ function clear() {
441
+ rmSync(configPath(), { force: true });
442
+ }
443
+ async function currentToken(baseUrl, fetchImpl = fetch) {
444
+ if (process.env.UNBROWSE_API_KEY)
445
+ return process.env.UNBROWSE_API_KEY;
446
+ const stored = load();
447
+ if (stored.baseUrl && stored.baseUrl !== baseUrl)
448
+ return;
449
+ if (stored.apiKey)
450
+ return stored.apiKey;
451
+ const o = stored.oauth;
452
+ if (!o)
453
+ return;
454
+ if (o.expiresAt && o.refreshToken && Date.now() > o.expiresAt - 60000) {
455
+ try {
456
+ const fresh = await tokenRequest(baseUrl, { grant_type: "refresh_token", refresh_token: o.refreshToken, client_id: o.clientId, resource: apiResource(baseUrl) }, fetchImpl);
457
+ save({ ...stored, oauth: { clientId: o.clientId, ...fresh, refreshToken: fresh.refreshToken ?? o.refreshToken } });
458
+ return fresh.accessToken;
459
+ } catch {
460
+ return o.accessToken;
461
+ }
462
+ }
463
+ return o.accessToken;
464
+ }
465
+ function describe(baseUrl) {
466
+ if (process.env.UNBROWSE_API_KEY)
467
+ return "API key from UNBROWSE_API_KEY";
468
+ const stored = load();
469
+ if (stored.baseUrl && stored.baseUrl !== baseUrl)
470
+ return `not signed in to ${baseUrl} (stored login is for ${stored.baseUrl})`;
471
+ if (stored.apiKey)
472
+ return `API key ${mask(stored.apiKey)} in ${configPath()}`;
473
+ if (stored.oauth)
474
+ return `OAuth sign-in in ${configPath()}`;
475
+ return "not signed in";
476
+ }
477
+ function mask(key) {
478
+ return key.length <= 12 ? "…" : `${key.slice(0, 8)}…${key.slice(-4)}`;
479
+ }
480
+ function apiResource(baseUrl) {
481
+ return `${baseUrl.replace(/\/+$/, "")}/api`;
482
+ }
483
+ async function tokenRequest(baseUrl, form, fetchImpl) {
484
+ const res = await fetchImpl(`${baseUrl}/oauth/token`, {
485
+ method: "POST",
486
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
487
+ body: new URLSearchParams(form).toString()
488
+ });
489
+ const body = await res.json().catch(() => ({}));
490
+ if (!res.ok || !body.access_token)
491
+ throw new Error(body.error_description ?? body.error ?? `token endpoint answered HTTP ${res.status}`);
492
+ return { accessToken: body.access_token, refreshToken: body.refresh_token, expiresAt: body.expires_in ? Date.now() + body.expires_in * 1000 : undefined };
493
+ }
494
+ var b64url = (buf) => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
495
+ async function oauthLogin(baseUrl, opts) {
496
+ const fetchImpl = opts.fetch ?? fetch;
497
+ const verifier = b64url(randomBytes(32));
498
+ const challenge = b64url(createHash("sha256").update(verifier).digest());
499
+ const state = b64url(randomBytes(16));
500
+ let settle;
501
+ const got = new Promise((r) => settle = r);
502
+ const server = createServer((req, res) => {
503
+ const u = new URL(req.url ?? "/", "http://127.0.0.1");
504
+ if (u.pathname !== "/callback") {
505
+ res.writeHead(404).end();
506
+ return;
507
+ }
508
+ const ok = u.searchParams.get("state") === state && !!u.searchParams.get("code");
509
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
510
+ res.end(ok ? "<p>Signed in to Unbrowse. You can close this tab.</p>" : "<p>Sign-in failed. Return to the terminal.</p>");
511
+ settle(ok ? { code: u.searchParams.get("code") } : { error: u.searchParams.get("error_description") ?? u.searchParams.get("error") ?? "state mismatch" });
512
+ });
513
+ await new Promise((r) => server.listen(0, "127.0.0.1", r));
514
+ const port = server.address().port;
515
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
516
+ try {
517
+ const reg = await fetchImpl(`${baseUrl}/oauth/register`, {
518
+ method: "POST",
519
+ headers: { "content-type": "application/json", accept: "application/json" },
520
+ body: JSON.stringify({
521
+ client_name: "Unbrowse CLI",
522
+ redirect_uris: [redirectUri],
523
+ grant_types: ["authorization_code", "refresh_token"],
524
+ response_types: ["code"],
525
+ token_endpoint_auth_method: "none"
526
+ })
527
+ });
528
+ const client = await reg.json().catch(() => ({}));
529
+ if (!reg.ok || !client.client_id)
530
+ throw new Error(`client registration failed: ${client.error_description ?? `HTTP ${reg.status}`}`);
531
+ const url = new URL(`${baseUrl}/authorize`);
532
+ url.search = new URLSearchParams({
533
+ response_type: "code",
534
+ client_id: client.client_id,
535
+ redirect_uri: redirectUri,
536
+ code_challenge: challenge,
537
+ code_challenge_method: "S256",
538
+ state,
539
+ scope: "unbrowse",
540
+ resource: apiResource(baseUrl)
541
+ }).toString();
542
+ opts.log(`Opening ${url.origin}/authorize in your browser. If it does not open, visit:
543
+ ${url.toString()}`);
544
+ opts.open(url.toString());
545
+ const timer = setTimeout(() => settle({ error: "timed out waiting for the browser sign-in" }), opts.timeoutMs ?? 300000);
546
+ const answer = await got;
547
+ clearTimeout(timer);
548
+ if (!answer.code)
549
+ throw new Error(answer.error ?? "sign-in failed");
550
+ const tokens = await tokenRequest(baseUrl, { grant_type: "authorization_code", code: answer.code, redirect_uri: redirectUri, client_id: client.client_id, code_verifier: verifier, resource: apiResource(baseUrl) }, fetchImpl);
551
+ save({ baseUrl, oauth: { clientId: client.client_id, ...tokens } });
552
+ } finally {
553
+ server.close();
554
+ }
555
+ }
556
+
314
557
  // src/cli.ts
315
- var VERSION = "12.0.1";
558
+ init_src();
559
+ var VERSION = "12.1.1";
316
560
  var DEFAULT_ORIGIN = "https://unbrowse.ai";
317
561
  var HELP = `unbrowse ${VERSION} — call websites as APIs through the Unbrowse API
318
562
 
@@ -324,6 +568,7 @@ var HELP = `unbrowse ${VERSION} — call websites as APIs through the Unbrowse A
324
568
  discover <query> Your capabilities first, then the public registry
325
569
  run <task…> Run a task and wait for a verified result
326
570
  [--capability ID] [--url URL] [--set key=value]… [--input JSON] [--unattended] [--no-wait]
571
+ [--from-here] send the site requests from this machine (your IP); Unbrowse decides and reads them
327
572
  inspect <runId> A run's status, requirements and result
328
573
  resume <runId> key=value… Answer open requirements on the same run
329
574
  cancel <runId> Stop a run; prints the effect receipt
@@ -336,8 +581,11 @@ var HELP = `unbrowse ${VERSION} — call websites as APIs through the Unbrowse A
336
581
  registry [query] Public compiled sites (no account)
337
582
  site <host> One site's tools (no account)
338
583
 
584
+ mcp Local stdio MCP server proxying the hosted one (tool names use _ not .)
585
+ [--url URL] [--end-user ID] for agent hosts that need stdio or strict tool names (Grok Build)
586
+
339
587
  Options: --json (errors as JSON) · --base-url URL · --no-open
340
- Env: UNBROWSE_API_KEY, UNBROWSE_BASE_URL (default ${DEFAULT_ORIGIN})
588
+ Env: UNBROWSE_API_KEY, UNBROWSE_BASE_URL (default ${DEFAULT_ORIGIN}), UNBROWSE_MCP_URL, UNBROWSE_END_USER
341
589
  Exit: 0 ok · 1 error · 2 input required · 3 sign-in or login needed · 4 not verified`;
342
590
  var BOOLEAN = new Set(["json", "help", "version", "no-open", "no-wait", "unattended"]);
343
591
  function parseArgs(argv) {
@@ -383,6 +631,16 @@ async function main(argv, io = stdio) {
383
631
  const flag = (k) => typeof args.flags[k] === "string" ? args.flags[k] : undefined;
384
632
  const print = (v) => io.out(JSON.stringify(v, null, 2));
385
633
  const client = async () => new Unbrowse({ apiKey: await currentToken(origin) ?? "", baseUrl: origin });
634
+ if (cmd === "mcp") {
635
+ await Promise.resolve().then(() => init_mcp_proxy());
636
+ await serveStdio({
637
+ url: flag("url") ?? process.env.UNBROWSE_MCP_URL ?? `${origin}/api/mcp`,
638
+ token: async () => await currentToken(origin) ?? undefined,
639
+ version: VERSION,
640
+ endUser: flag("end-user") ?? process.env.UNBROWSE_END_USER
641
+ });
642
+ return 0;
643
+ }
386
644
  try {
387
645
  const ub = await client();
388
646
  switch (cmd) {
@@ -415,14 +673,15 @@ async function main(argv, io = stdio) {
415
673
  if (!task && !capability)
416
674
  throw new UsageError("usage: unbrowse run <task…> | --capability ID");
417
675
  const input = { ...flag("input") ? JSON.parse(flag("input")) : {}, ...mapValues(args.sets) };
418
- const view = await ub.run({
676
+ const request = {
419
677
  ...task ? { task } : {},
420
678
  ...capability ? { capability } : {},
421
679
  ...flag("url") ? { targetUrl: flag("url") } : {},
422
680
  ...Object.keys(input).length ? { input } : {},
423
681
  ...args.flags.unattended ? { interactionMode: "unattended" } : {},
424
682
  idempotencyKey: flag("idempotency-key") ?? randomUUID()
425
- });
683
+ };
684
+ const view = args.flags["from-here"] ? await ub.runOnClient(request, { onRequest: (r) => void io.err(`→ ${r.method} ${r.url}`) }) : await ub.run(request);
426
685
  return settle(ub, view, args, io, print);
427
686
  }
428
687
  case "inspect":
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "unbrowse",
3
- "version": "12.0.1",
3
+ "version": "12.1.1",
4
+ "mcpName": "io.github.unbrowse-ai/unbrowse",
4
5
  "description": "Call websites as APIs: a thin CLI over the Unbrowse API.",
5
6
  "license": "MIT",
6
7
  "type": "module",