unbrowse 12.0.0 → 12.1.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.
Files changed (4) hide show
  1. package/README.md +2 -1
  2. package/SKILL.md +12 -1
  3. package/dist/cli.js +355 -175
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -18,7 +18,8 @@ unbrowse resume <runId> origin=SIN # answer on the same run
18
18
  | `learn a.har b.har`, `learned [id]` | Teach a site from two recordings |
19
19
  | `logins`, `logins remove <origin>` | Saved logins, masked. The CLI never takes a password |
20
20
  | `registry [query]`, `site <host>` | Public compiled sites, no account |
21
- | `install` | Add the hosted MCP to Claude Code, Codex and others |
21
+
22
+ The CLI is the REST client. The remote MCP is a separate client; see the [install guide](https://github.com/unbrowse-ai/unbrowse-skill/blob/main/docs/install.md).
22
23
 
23
24
  Exit codes: 0 accepted/ok (check status and verified), 1 error, 2 input required, 3 sign-in or saved login needed, 4 not verified.
24
25
  Env: `UNBROWSE_API_KEY`, `UNBROWSE_BASE_URL`. Node 18.17+, no dependencies.
package/SKILL.md CHANGED
@@ -25,10 +25,21 @@ 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.
31
- - CLI: `unbrowse discover`, `run`, `inspect`, `resume`, `registry`; `unbrowse install` prints MCP configuration. `unbrowse help` describes the installed version.
42
+ - CLI: `unbrowse discover`, `run`, `inspect`, `resume`, `registry`. It calls the REST API and is not an MCP client. `unbrowse help` describes the installed version.
32
43
  - SDK: REST integration and scripting. Consult the public SDK docs for its supported methods; MCP tools and REST methods are not interchangeable names.
33
44
 
34
45
  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`, and the cloud browser `unbrowse.browse.open`, `unbrowse.browse.snapshot`, `unbrowse.browse.act`, `unbrowse.browse.finish`, `unbrowse.browse.close`.
package/dist/cli.js CHANGED
@@ -1,166 +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 }, 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
- async function tokenRequest(baseUrl, form, fetchImpl) {
75
- const res = await fetchImpl(`${baseUrl}/oauth/token`, {
76
- method: "POST",
77
- headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
78
- body: new URLSearchParams(form).toString()
79
- });
80
- const body = await res.json().catch(() => ({}));
81
- if (!res.ok || !body.access_token)
82
- throw new Error(body.error_description ?? body.error ?? `token endpoint answered HTTP ${res.status}`);
83
- return { accessToken: body.access_token, refreshToken: body.refresh_token, expiresAt: body.expires_in ? Date.now() + body.expires_in * 1000 : undefined };
84
- }
85
- var b64url = (buf) => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
86
- async function oauthLogin(baseUrl, opts) {
87
- const fetchImpl = opts.fetch ?? fetch;
88
- const verifier = b64url(randomBytes(32));
89
- const challenge = b64url(createHash("sha256").update(verifier).digest());
90
- const state = b64url(randomBytes(16));
91
- let settle;
92
- const got = new Promise((r) => settle = r);
93
- const server = createServer((req, res) => {
94
- const u = new URL(req.url ?? "/", "http://127.0.0.1");
95
- if (u.pathname !== "/callback") {
96
- res.writeHead(404).end();
97
- return;
98
- }
99
- const ok = u.searchParams.get("state") === state && !!u.searchParams.get("code");
100
- res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
101
- res.end(ok ? "<p>Signed in to Unbrowse. You can close this tab.</p>" : "<p>Sign-in failed. Return to the terminal.</p>");
102
- settle(ok ? { code: u.searchParams.get("code") } : { error: u.searchParams.get("error_description") ?? u.searchParams.get("error") ?? "state mismatch" });
103
- });
104
- await new Promise((r) => server.listen(0, "127.0.0.1", r));
105
- const port = server.address().port;
106
- const redirectUri = `http://127.0.0.1:${port}/callback`;
107
- try {
108
- const reg = await fetchImpl(`${baseUrl}/oauth/register`, {
109
- method: "POST",
110
- headers: { "content-type": "application/json", accept: "application/json" },
111
- body: JSON.stringify({
112
- client_name: "Unbrowse CLI",
113
- redirect_uris: [redirectUri],
114
- grant_types: ["authorization_code", "refresh_token"],
115
- response_types: ["code"],
116
- token_endpoint_auth_method: "none"
117
- })
118
- });
119
- const client = await reg.json().catch(() => ({}));
120
- if (!reg.ok || !client.client_id)
121
- throw new Error(`client registration failed: ${client.error_description ?? `HTTP ${reg.status}`}`);
122
- const url = new URL(`${baseUrl}/authorize`);
123
- url.search = new URLSearchParams({
124
- response_type: "code",
125
- client_id: client.client_id,
126
- redirect_uri: redirectUri,
127
- code_challenge: challenge,
128
- code_challenge_method: "S256",
129
- state,
130
- scope: "unbrowse",
131
- resource: `${baseUrl}/mcp`
132
- }).toString();
133
- opts.log(`Opening ${url.origin}/authorize in your browser. If it does not open, visit:
134
- ${url.toString()}`);
135
- opts.open(url.toString());
136
- const timer = setTimeout(() => settle({ error: "timed out waiting for the browser sign-in" }), opts.timeoutMs ?? 300000);
137
- const answer = await got;
138
- clearTimeout(timer);
139
- if (!answer.code)
140
- throw new Error(answer.error ?? "sign-in failed");
141
- const tokens = await tokenRequest(baseUrl, { grant_type: "authorization_code", code: answer.code, redirect_uri: redirectUri, client_id: client.client_id, code_verifier: verifier }, fetchImpl);
142
- save({ baseUrl, oauth: { clientId: client.client_id, ...tokens } });
143
- } finally {
144
- server.close();
145
- }
146
- }
9
+ if (err)
10
+ throw err[0];
11
+ return res;
12
+ };
147
13
 
148
14
  // ../sdk/src/client.ts
149
- var DEFAULT_BASE_URL = "https://unbrowse.ai/api/v1";
150
-
151
- class UnbrowseError extends Error {
152
- status;
153
- code;
154
- body;
155
- constructor(message, status, code, body) {
156
- super(message);
157
- this.status = status;
158
- this.code = code;
159
- this.body = body;
160
- this.name = "UnbrowseError";
161
- }
162
- }
163
- var env = (name) => (typeof process !== "undefined" ? process.env?.[name] : undefined) || undefined;
164
15
  function apiBase(url) {
165
16
  const u = url.replace(/\/+$/, "");
166
17
  return /\/api\/v1$/.test(u) ? u : `${u}/api/v1`;
@@ -308,16 +159,341 @@ function normalizeHost(site) {
308
159
  host = new URL(host).hostname;
309
160
  return host.replace(/\/.*$/, "").replace(/^www\./, "");
310
161
  }
311
- // ../sdk/src/mcp-install.ts
312
- function mcpCommands(url) {
313
- return {
314
- claudeCode: `claude mcp add --transport http unbrowse ${url}`,
315
- codex: `codex mcp add unbrowse --url ${url} && codex mcp login unbrowse`,
316
- json: JSON.stringify({ mcpServers: { unbrowse: { url } } }, null, 2)
162
+ var DEFAULT_BASE_URL = "https://unbrowse.ai/api/v1", UnbrowseError, env = (name) => (typeof process !== "undefined" ? process.env?.[name] : undefined) || undefined;
163
+ var init_client = __esm(() => {
164
+ UnbrowseError = class UnbrowseError extends Error {
165
+ status;
166
+ code;
167
+ body;
168
+ constructor(message, status, code, body) {
169
+ super(message);
170
+ this.status = status;
171
+ this.code = code;
172
+ this.body = body;
173
+ this.name = "UnbrowseError";
174
+ }
175
+ };
176
+ });
177
+ // ../sdk/src/mcp.ts
178
+ function parseRpcBody(text) {
179
+ const trimmed = text.trim();
180
+ if (trimmed.startsWith("{"))
181
+ return JSON.parse(trimmed);
182
+ const events = trimmed.split(/\n\n+/).flatMap((block) => {
183
+ const data = block.split(`
184
+ `).filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trimStart()).join(`
185
+ `);
186
+ return data ? [data] : [];
187
+ });
188
+ if (!events.length)
189
+ throw new UnbrowseError("Empty MCP response", 502, "mcp_empty");
190
+ return JSON.parse(events[events.length - 1]);
191
+ }
192
+
193
+ class UnbrowseMcp {
194
+ url;
195
+ apiKey;
196
+ endUser;
197
+ client;
198
+ fetchImpl;
199
+ nextId = 1;
200
+ constructor(opts = {}) {
201
+ this.url = (opts.url ?? env2("UNBROWSE_MCP_URL") ?? DEFAULT_MCP_URL).replace(/\/+$/, "");
202
+ this.apiKey = opts.apiKey ?? env2("UNBROWSE_API_KEY");
203
+ this.endUser = opts.endUser ?? env2("UNBROWSE_END_USER");
204
+ this.client = opts.client ?? "sdk";
205
+ this.fetchImpl = opts.fetch ?? ((...a) => globalThis.fetch(...a));
206
+ }
207
+ async rpc(method, params) {
208
+ const res = await this.fetchImpl(this.url, {
209
+ method: "POST",
210
+ headers: {
211
+ ...this.apiKey ? { authorization: `Bearer ${this.apiKey}` } : {},
212
+ ...this.endUser ? { "x-unbrowse-end-user": this.endUser } : {},
213
+ "content-type": "application/json",
214
+ accept: "application/json, text/event-stream",
215
+ "mcp-protocol-version": MCP_PROTOCOL_VERSION,
216
+ "user-agent": `unbrowse-${this.client}`
217
+ },
218
+ body: JSON.stringify({ jsonrpc: "2.0", id: this.nextId++, method, ...params ? { params } : {} })
219
+ });
220
+ const text = await res.text();
221
+ if (!res.ok) {
222
+ let body = {};
223
+ try {
224
+ body = JSON.parse(text);
225
+ } catch {}
226
+ const err = typeof body.error === "object" ? body.error : undefined;
227
+ 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);
228
+ }
229
+ const msg = parseRpcBody(text);
230
+ if (msg.error)
231
+ throw new UnbrowseError(msg.error.message, 200, msg.error.data?.code ?? `rpc_${msg.error.code}`, msg.error);
232
+ return msg.result;
233
+ }
234
+ async listTools() {
235
+ const tools = [];
236
+ let cursor;
237
+ do {
238
+ const page = await this.rpc("tools/list", cursor ? { cursor } : undefined);
239
+ tools.push(...page.tools);
240
+ cursor = page.nextCursor;
241
+ } while (cursor);
242
+ return tools;
243
+ }
244
+ callTool(name, args = {}) {
245
+ return this.rpc("tools/call", { name, arguments: args });
246
+ }
247
+ }
248
+ var DEFAULT_MCP_URL = "https://unbrowse.ai/mcp", MCP_PROTOCOL_VERSION = "2025-11-25", env2 = (name) => (typeof process !== "undefined" ? process.env?.[name] : undefined) || undefined;
249
+ var init_mcp = __esm(() => {
250
+ init_client();
251
+ });
252
+
253
+ // ../sdk/src/index.ts
254
+ var init_src = __esm(() => {
255
+ init_client();
256
+ init_mcp();
257
+ });
258
+
259
+ // src/mcp-proxy.ts
260
+ import { createInterface } from "node:readline";
261
+ function safeToolName(name) {
262
+ return name.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64);
263
+ }
264
+ function createProxy(upstream, version) {
265
+ const names = new Map;
266
+ async function list() {
267
+ const tools = await (await upstream()).listTools();
268
+ names.clear();
269
+ const out = [];
270
+ for (const t of tools) {
271
+ const safe = safeToolName(t.name);
272
+ if (names.has(safe))
273
+ continue;
274
+ names.set(safe, t.name);
275
+ out.push({ ...t, name: safe });
276
+ }
277
+ return out;
278
+ }
279
+ async function original(safe) {
280
+ if (!names.has(safe))
281
+ await list();
282
+ return names.get(safe) ?? safe;
283
+ }
284
+ return async function handle(msg) {
285
+ if (msg.id === undefined || msg.id === null)
286
+ return;
287
+ const reply = (result) => ({ jsonrpc: "2.0", id: msg.id, result });
288
+ try {
289
+ switch (msg.method) {
290
+ case "initialize": {
291
+ const asked = String(msg.params?.protocolVersion ?? "");
292
+ return reply({
293
+ protocolVersion: PROTOCOLS.includes(asked) ? asked : PROTOCOLS[0],
294
+ capabilities: { tools: { listChanged: false } },
295
+ serverInfo: { name: "unbrowse", title: "Unbrowse (local proxy to the hosted MCP)", version },
296
+ 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."
297
+ });
298
+ }
299
+ case "ping":
300
+ return reply({});
301
+ case "tools/list":
302
+ return reply({ tools: await list() });
303
+ case "tools/call": {
304
+ const name = String(msg.params?.name ?? "");
305
+ return reply(await (await upstream()).rpc("tools/call", { ...msg.params, name: await original(name) }));
306
+ }
307
+ default:
308
+ return reply(await (await upstream()).rpc(String(msg.method), msg.params));
309
+ }
310
+ } catch (e) {
311
+ const err = e;
312
+ const code = err instanceof UnbrowseError ? err.code : "proxy_error";
313
+ const hint = err instanceof UnbrowseError && err.status === 401 ? " Run `unbrowse login` (or set UNBROWSE_API_KEY) and restart this server." : "";
314
+ return { jsonrpc: "2.0", id: msg.id, error: { code: -32000, message: `${err.message}${hint}`, data: { code } } };
315
+ }
317
316
  };
318
317
  }
318
+ async function serveStdio(opts) {
319
+ const handle = createProxy(async () => new UnbrowseMcp({ url: opts.url, apiKey: await opts.token() ?? "", endUser: opts.endUser, client: `cli-mcp/${opts.version}` }), opts.version);
320
+ const write = (m) => new Promise((resolve) => process.stdout.write(`${JSON.stringify(m)}
321
+ `, () => resolve()));
322
+ const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
323
+ const pending = new Set;
324
+ for await (const line of rl) {
325
+ if (!line.trim())
326
+ continue;
327
+ let msg;
328
+ try {
329
+ msg = JSON.parse(line);
330
+ } catch {
331
+ pending.add(write({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } }));
332
+ continue;
333
+ }
334
+ const p = handle(msg).then((res) => res ? write(res) : undefined);
335
+ pending.add(p);
336
+ p.finally(() => pending.delete(p));
337
+ }
338
+ await Promise.all(pending);
339
+ }
340
+ var PROTOCOLS;
341
+ var init_mcp_proxy = __esm(() => {
342
+ init_src();
343
+ PROTOCOLS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
344
+ });
345
+
319
346
  // src/cli.ts
320
- var VERSION = "12.0.0";
347
+ import { spawn } from "node:child_process";
348
+ import { randomUUID } from "node:crypto";
349
+ import { readFileSync as readFileSync2 } from "node:fs";
350
+
351
+ // src/auth.ts
352
+ import { createHash, randomBytes } from "node:crypto";
353
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
354
+ import { createServer } from "node:http";
355
+ import { homedir } from "node:os";
356
+ import { dirname, join } from "node:path";
357
+ function configPath() {
358
+ const root = process.env.UNBROWSE_CONFIG_DIR ?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "unbrowse");
359
+ return join(root, "cli.json");
360
+ }
361
+ function load() {
362
+ const path = configPath();
363
+ if (!existsSync(path))
364
+ return {};
365
+ try {
366
+ return JSON.parse(readFileSync(path, "utf8"));
367
+ } catch {
368
+ return {};
369
+ }
370
+ }
371
+ function save(stored) {
372
+ const path = configPath();
373
+ mkdirSync(dirname(path), { recursive: true, mode: 448 });
374
+ writeFileSync(path, JSON.stringify(stored, null, 2), { mode: 384 });
375
+ chmodSync(path, 384);
376
+ }
377
+ function clear() {
378
+ rmSync(configPath(), { force: true });
379
+ }
380
+ async function currentToken(baseUrl, fetchImpl = fetch) {
381
+ if (process.env.UNBROWSE_API_KEY)
382
+ return process.env.UNBROWSE_API_KEY;
383
+ const stored = load();
384
+ if (stored.baseUrl && stored.baseUrl !== baseUrl)
385
+ return;
386
+ if (stored.apiKey)
387
+ return stored.apiKey;
388
+ const o = stored.oauth;
389
+ if (!o)
390
+ return;
391
+ if (o.expiresAt && o.refreshToken && Date.now() > o.expiresAt - 60000) {
392
+ try {
393
+ const fresh = await tokenRequest(baseUrl, { grant_type: "refresh_token", refresh_token: o.refreshToken, client_id: o.clientId, resource: apiResource(baseUrl) }, fetchImpl);
394
+ save({ ...stored, oauth: { clientId: o.clientId, ...fresh, refreshToken: fresh.refreshToken ?? o.refreshToken } });
395
+ return fresh.accessToken;
396
+ } catch {
397
+ return o.accessToken;
398
+ }
399
+ }
400
+ return o.accessToken;
401
+ }
402
+ function describe(baseUrl) {
403
+ if (process.env.UNBROWSE_API_KEY)
404
+ return "API key from UNBROWSE_API_KEY";
405
+ const stored = load();
406
+ if (stored.baseUrl && stored.baseUrl !== baseUrl)
407
+ return `not signed in to ${baseUrl} (stored login is for ${stored.baseUrl})`;
408
+ if (stored.apiKey)
409
+ return `API key ${mask(stored.apiKey)} in ${configPath()}`;
410
+ if (stored.oauth)
411
+ return `OAuth sign-in in ${configPath()}`;
412
+ return "not signed in";
413
+ }
414
+ function mask(key) {
415
+ return key.length <= 12 ? "…" : `${key.slice(0, 8)}…${key.slice(-4)}`;
416
+ }
417
+ function apiResource(baseUrl) {
418
+ return `${baseUrl.replace(/\/+$/, "")}/api`;
419
+ }
420
+ async function tokenRequest(baseUrl, form, fetchImpl) {
421
+ const res = await fetchImpl(`${baseUrl}/oauth/token`, {
422
+ method: "POST",
423
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
424
+ body: new URLSearchParams(form).toString()
425
+ });
426
+ const body = await res.json().catch(() => ({}));
427
+ if (!res.ok || !body.access_token)
428
+ throw new Error(body.error_description ?? body.error ?? `token endpoint answered HTTP ${res.status}`);
429
+ return { accessToken: body.access_token, refreshToken: body.refresh_token, expiresAt: body.expires_in ? Date.now() + body.expires_in * 1000 : undefined };
430
+ }
431
+ var b64url = (buf) => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
432
+ async function oauthLogin(baseUrl, opts) {
433
+ const fetchImpl = opts.fetch ?? fetch;
434
+ const verifier = b64url(randomBytes(32));
435
+ const challenge = b64url(createHash("sha256").update(verifier).digest());
436
+ const state = b64url(randomBytes(16));
437
+ let settle;
438
+ const got = new Promise((r) => settle = r);
439
+ const server = createServer((req, res) => {
440
+ const u = new URL(req.url ?? "/", "http://127.0.0.1");
441
+ if (u.pathname !== "/callback") {
442
+ res.writeHead(404).end();
443
+ return;
444
+ }
445
+ const ok = u.searchParams.get("state") === state && !!u.searchParams.get("code");
446
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
447
+ res.end(ok ? "<p>Signed in to Unbrowse. You can close this tab.</p>" : "<p>Sign-in failed. Return to the terminal.</p>");
448
+ settle(ok ? { code: u.searchParams.get("code") } : { error: u.searchParams.get("error_description") ?? u.searchParams.get("error") ?? "state mismatch" });
449
+ });
450
+ await new Promise((r) => server.listen(0, "127.0.0.1", r));
451
+ const port = server.address().port;
452
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
453
+ try {
454
+ const reg = await fetchImpl(`${baseUrl}/oauth/register`, {
455
+ method: "POST",
456
+ headers: { "content-type": "application/json", accept: "application/json" },
457
+ body: JSON.stringify({
458
+ client_name: "Unbrowse CLI",
459
+ redirect_uris: [redirectUri],
460
+ grant_types: ["authorization_code", "refresh_token"],
461
+ response_types: ["code"],
462
+ token_endpoint_auth_method: "none"
463
+ })
464
+ });
465
+ const client = await reg.json().catch(() => ({}));
466
+ if (!reg.ok || !client.client_id)
467
+ throw new Error(`client registration failed: ${client.error_description ?? `HTTP ${reg.status}`}`);
468
+ const url = new URL(`${baseUrl}/authorize`);
469
+ url.search = new URLSearchParams({
470
+ response_type: "code",
471
+ client_id: client.client_id,
472
+ redirect_uri: redirectUri,
473
+ code_challenge: challenge,
474
+ code_challenge_method: "S256",
475
+ state,
476
+ scope: "unbrowse",
477
+ resource: apiResource(baseUrl)
478
+ }).toString();
479
+ opts.log(`Opening ${url.origin}/authorize in your browser. If it does not open, visit:
480
+ ${url.toString()}`);
481
+ opts.open(url.toString());
482
+ const timer = setTimeout(() => settle({ error: "timed out waiting for the browser sign-in" }), opts.timeoutMs ?? 300000);
483
+ const answer = await got;
484
+ clearTimeout(timer);
485
+ if (!answer.code)
486
+ throw new Error(answer.error ?? "sign-in failed");
487
+ 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);
488
+ save({ baseUrl, oauth: { clientId: client.client_id, ...tokens } });
489
+ } finally {
490
+ server.close();
491
+ }
492
+ }
493
+
494
+ // src/cli.ts
495
+ init_src();
496
+ var VERSION = "12.1.0";
321
497
  var DEFAULT_ORIGIN = "https://unbrowse.ai";
322
498
  var HELP = `unbrowse ${VERSION} — call websites as APIs through the Unbrowse API
323
499
 
@@ -340,10 +516,12 @@ var HELP = `unbrowse ${VERSION} — call websites as APIs through the Unbrowse A
340
516
 
341
517
  registry [query] Public compiled sites (no account)
342
518
  site <host> One site's tools (no account)
343
- install Add the Unbrowse MCP to Claude Code, Codex or any client
519
+
520
+ mcp Local stdio MCP server proxying the hosted one (tool names use _ not .)
521
+ [--url URL] [--end-user ID] for agent hosts that need stdio or strict tool names (Grok Build)
344
522
 
345
523
  Options: --json (errors as JSON) · --base-url URL · --no-open
346
- Env: UNBROWSE_API_KEY, UNBROWSE_BASE_URL (default ${DEFAULT_ORIGIN})
524
+ Env: UNBROWSE_API_KEY, UNBROWSE_BASE_URL (default ${DEFAULT_ORIGIN}), UNBROWSE_MCP_URL, UNBROWSE_END_USER
347
525
  Exit: 0 ok · 1 error · 2 input required · 3 sign-in or login needed · 4 not verified`;
348
526
  var BOOLEAN = new Set(["json", "help", "version", "no-open", "no-wait", "unattended"]);
349
527
  function parseArgs(argv) {
@@ -389,6 +567,16 @@ async function main(argv, io = stdio) {
389
567
  const flag = (k) => typeof args.flags[k] === "string" ? args.flags[k] : undefined;
390
568
  const print = (v) => io.out(JSON.stringify(v, null, 2));
391
569
  const client = async () => new Unbrowse({ apiKey: await currentToken(origin) ?? "", baseUrl: origin });
570
+ if (cmd === "mcp") {
571
+ await Promise.resolve().then(() => init_mcp_proxy());
572
+ await serveStdio({
573
+ url: flag("url") ?? process.env.UNBROWSE_MCP_URL ?? `${origin}/api/mcp`,
574
+ token: async () => await currentToken(origin) ?? undefined,
575
+ version: VERSION,
576
+ endUser: flag("end-user") ?? process.env.UNBROWSE_END_USER
577
+ });
578
+ return 0;
579
+ }
392
580
  try {
393
581
  const ub = await client();
394
582
  switch (cmd) {
@@ -470,14 +658,6 @@ async function main(argv, io = stdio) {
470
658
  case "site":
471
659
  print(await ub.site(need(rest[0], "site <host>")));
472
660
  return 0;
473
- case "install": {
474
- const c = mcpCommands(`${origin}/mcp`);
475
- io.out(`Claude Code: ${c.claudeCode}
476
- Codex: ${c.codex}
477
- Other clients:
478
- ${c.json}`);
479
- return 0;
480
- }
481
661
  default:
482
662
  throw new UsageError(`unknown command "${cmd}". Run \`unbrowse help\`.`);
483
663
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbrowse",
3
- "version": "12.0.0",
3
+ "version": "12.1.0",
4
4
  "description": "Call websites as APIs: a thin CLI over the Unbrowse API.",
5
5
  "license": "MIT",
6
6
  "type": "module",