@unbrowse/sdk 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.
- package/README.md +11 -0
- package/dist/client.d.ts +26 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +148 -0
- package/dist/mcp.d.ts +60 -0
- package/dist/types.d.ts +33 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,6 +37,17 @@ an OAuth access token); `baseUrl` takes an origin or an `/api/v1` URL and defaul
|
|
|
37
37
|
| `answer(runId, { field: value })` | `GET /runs/:id`, `POST /runs/:id/responses`, `GET /runs/:id` |
|
|
38
38
|
| `resume(runId, expectedStateRevision, responses)` | `POST /runs/:id/responses` |
|
|
39
39
|
| `cancel(runId)` | `POST /runs/:id/cancel` |
|
|
40
|
+
| `runOnClient(request, { fetch?, onRequest?, timeoutMs? })` | `POST /runs` with `egress: "client"`, then `POST /egress/:id` per request |
|
|
41
|
+
| `egress(egressId)` / `answerEgress(egressId, requestId, { response } \| { error })` / `closeEgress(egressId)` | `GET` / `POST` / `DELETE /egress/:id` |
|
|
42
|
+
|
|
43
|
+
`runOnClient` sends the run's requests to the website from this machine, so the site sees your IP;
|
|
44
|
+
Unbrowse decides each request and reads each response, and never contacts the site itself:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
const run = await unbrowse.runOnClient({ capability: "hn.top_stories", input: { limit: 3 } });
|
|
48
|
+
// Your own network stack or proxy, and a look at each request before it leaves:
|
|
49
|
+
await unbrowse.runOnClient({ task: "search eatigo for italian" }, { fetch: myFetch, onRequest: (r) => allowed(r.url) });
|
|
50
|
+
```
|
|
40
51
|
|
|
41
52
|
A run is `succeeded` only when its result is verified (`verified: true`). `input_required` is not a
|
|
42
53
|
failure: answer on the same run. `outcome_unknown` means a change may have happened; do not retry
|
package/dist/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Json, RunRequest, RunView } from "./types.ts";
|
|
1
|
+
import type { EgressRequest, EgressResponse, EgressStep, Json, RunRequest, RunView } from "./types.ts";
|
|
2
2
|
export declare const DEFAULT_BASE_URL = "https://unbrowse.ai/api/v1";
|
|
3
3
|
export type UnbrowseOptions = {
|
|
4
4
|
/** API key (`ub_live_…`) or OAuth access token. Defaults to `UNBROWSE_API_KEY`. Public routes need none. */
|
|
@@ -43,6 +43,29 @@ export declare class Unbrowse {
|
|
|
43
43
|
timeoutMs?: number;
|
|
44
44
|
}): Promise<RunView>;
|
|
45
45
|
cancel(runId: string): Promise<any>;
|
|
46
|
+
/**
|
|
47
|
+
* Run with the site requests sent from this machine. Unbrowse chooses each request and reads each response;
|
|
48
|
+
* this sends them with `fetch` (yours by default) and posts the raw responses back until the run finishes.
|
|
49
|
+
* `onRequest` sees each request first; return `false` to refuse it (the run gets a network error).
|
|
50
|
+
*/
|
|
51
|
+
runOnClient(request: Omit<RunRequest, "egress">, opts?: {
|
|
52
|
+
fetch?: typeof globalThis.fetch;
|
|
53
|
+
onRequest?: (req: EgressRequest) => boolean | void | Promise<boolean | void>;
|
|
54
|
+
timeoutMs?: number;
|
|
55
|
+
}): Promise<RunView>;
|
|
56
|
+
/** What a client-egress run is waiting for: the next request(s), or the finished run. */
|
|
57
|
+
egress(egressId: string): Promise<RunView | EgressStep>;
|
|
58
|
+
/** Post the site's response (or why it could not be sent) for one request; returns what comes next. */
|
|
59
|
+
answerEgress(egressId: string, requestId: string, answer: {
|
|
60
|
+
response: EgressResponse;
|
|
61
|
+
} | {
|
|
62
|
+
error: string;
|
|
63
|
+
}): Promise<RunView | EgressStep>;
|
|
64
|
+
/** Abandon a client-egress run. */
|
|
65
|
+
closeEgress(egressId: string): Promise<{
|
|
66
|
+
egressId: string;
|
|
67
|
+
status: "closed";
|
|
68
|
+
}>;
|
|
46
69
|
/** Your private capabilities first, then the public registry. */
|
|
47
70
|
discover(query: string): Promise<any>;
|
|
48
71
|
capability(id: string): Promise<any>;
|
|
@@ -125,3 +148,5 @@ export type LoginView = {
|
|
|
125
148
|
createdAt: number;
|
|
126
149
|
rotatedAt?: number;
|
|
127
150
|
};
|
|
151
|
+
/** Whether a run answer is a client-egress step (requests to send) rather than a run. */
|
|
152
|
+
export declare function isEgressStep(v: unknown): v is EgressStep;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
export { DEFAULT_BASE_URL, Unbrowse, UnbrowseError, normalizeHost } from "./client.ts";
|
|
1
|
+
export { DEFAULT_BASE_URL, Unbrowse, UnbrowseError, isEgressStep, normalizeHost } from "./client.ts";
|
|
2
2
|
export type { LoginView, UnbrowseOptions } from "./client.ts";
|
|
3
3
|
export type * from "./types.ts";
|
|
4
4
|
export { cursorInstallLink, mcpCommands, vscodeInstallLink } from "./mcp-install.ts";
|
|
5
|
+
export { DEFAULT_MCP_URL, MCP_PROTOCOL_VERSION, UnbrowseMcp, parseRpcBody, resultText } from "./mcp.ts";
|
|
6
|
+
export type { McpContent, McpOptions, McpTool, McpToolResult } from "./mcp.ts";
|
package/dist/index.js
CHANGED
|
@@ -100,6 +100,43 @@ class Unbrowse {
|
|
|
100
100
|
cancel(runId) {
|
|
101
101
|
return this.req(`/runs/${runId}/cancel`, { method: "POST" });
|
|
102
102
|
}
|
|
103
|
+
async runOnClient(request, opts = {}) {
|
|
104
|
+
const send = opts.fetch ?? ((...a) => globalThis.fetch(...a));
|
|
105
|
+
const deadline = Date.now() + (opts.timeoutMs ?? 600000);
|
|
106
|
+
let step = await this.run({ ...request, egress: "client" });
|
|
107
|
+
while (isEgressStep(step)) {
|
|
108
|
+
if (Date.now() > deadline) {
|
|
109
|
+
await this.closeEgress(step.egressId).catch(() => {
|
|
110
|
+
return;
|
|
111
|
+
});
|
|
112
|
+
throw new UnbrowseError("client-egress run did not finish in time", 408, "timeout");
|
|
113
|
+
}
|
|
114
|
+
const { egressId } = step;
|
|
115
|
+
for (const req of step.requests) {
|
|
116
|
+
let answer;
|
|
117
|
+
try {
|
|
118
|
+
if (await opts.onRequest?.(req) === false)
|
|
119
|
+
throw new Error("refused by onRequest");
|
|
120
|
+
answer = { response: await sendEgress(send, req) };
|
|
121
|
+
} catch (err) {
|
|
122
|
+
answer = { error: err instanceof Error ? err.message : String(err) };
|
|
123
|
+
}
|
|
124
|
+
step = await this.answerEgress(egressId, req.id, answer);
|
|
125
|
+
if (!isEgressStep(step))
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return step;
|
|
130
|
+
}
|
|
131
|
+
egress(egressId) {
|
|
132
|
+
return this.req(`/egress/${egressId}`);
|
|
133
|
+
}
|
|
134
|
+
answerEgress(egressId, requestId, answer) {
|
|
135
|
+
return this.post(`/egress/${egressId}`, { requestId, ...answer });
|
|
136
|
+
}
|
|
137
|
+
closeEgress(egressId) {
|
|
138
|
+
return this.req(`/egress/${egressId}`, { method: "DELETE" });
|
|
139
|
+
}
|
|
103
140
|
discover(query) {
|
|
104
141
|
return this.post("/capabilities/search", { query });
|
|
105
142
|
}
|
|
@@ -161,6 +198,32 @@ function normalizeHost(site) {
|
|
|
161
198
|
host = new URL(host).hostname;
|
|
162
199
|
return host.replace(/\/.*$/, "").replace(/^www\./, "");
|
|
163
200
|
}
|
|
201
|
+
function isEgressStep(v) {
|
|
202
|
+
return !!v && typeof v === "object" && v.status === "egress_required";
|
|
203
|
+
}
|
|
204
|
+
async function sendEgress(send, req) {
|
|
205
|
+
const body = req.body === undefined ? undefined : req.bodyEncoding === "base64" ? base64ToBytes(req.body) : req.body;
|
|
206
|
+
const res = await send(req.url, { method: req.method, headers: req.headers, redirect: req.redirect, ...body !== undefined ? { body } : {} });
|
|
207
|
+
const headers = [];
|
|
208
|
+
res.headers.forEach((value, name) => headers.push([name, value]));
|
|
209
|
+
const cookies = res.headers.getSetCookie?.();
|
|
210
|
+
const pairs = cookies?.length ? [...headers.filter(([n]) => n.toLowerCase() !== "set-cookie"), ...cookies.map((c) => ["set-cookie", c])] : headers;
|
|
211
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
212
|
+
return { status: res.status, headers: pairs, body: bytesToBase64(bytes), bodyEncoding: "base64", ...res.url ? { url: res.url } : {} };
|
|
213
|
+
}
|
|
214
|
+
function base64ToBytes(b64) {
|
|
215
|
+
const bin = atob(b64);
|
|
216
|
+
const out = new Uint8Array(bin.length);
|
|
217
|
+
for (let i = 0;i < bin.length; i++)
|
|
218
|
+
out[i] = bin.charCodeAt(i);
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
function bytesToBase64(bytes) {
|
|
222
|
+
let bin = "";
|
|
223
|
+
for (let i = 0;i < bytes.length; i += 32768)
|
|
224
|
+
bin += String.fromCharCode(...bytes.subarray(i, i + 32768));
|
|
225
|
+
return btoa(bin);
|
|
226
|
+
}
|
|
164
227
|
// src/mcp-install.ts
|
|
165
228
|
function cursorInstallLink(url) {
|
|
166
229
|
return `cursor://anysphere.cursor-deeplink/mcp/install?name=unbrowse&config=${encodeURIComponent(btoa(JSON.stringify({ url })))}`;
|
|
@@ -175,12 +238,97 @@ function mcpCommands(url) {
|
|
|
175
238
|
json: JSON.stringify({ mcpServers: { unbrowse: { url } } }, null, 2)
|
|
176
239
|
};
|
|
177
240
|
}
|
|
241
|
+
// src/mcp.ts
|
|
242
|
+
var DEFAULT_MCP_URL = "https://unbrowse.ai/mcp";
|
|
243
|
+
var MCP_PROTOCOL_VERSION = "2025-11-25";
|
|
244
|
+
var env2 = (name) => (typeof process !== "undefined" ? process.env?.[name] : undefined) || undefined;
|
|
245
|
+
function parseRpcBody(text) {
|
|
246
|
+
const trimmed = text.trim();
|
|
247
|
+
if (trimmed.startsWith("{"))
|
|
248
|
+
return JSON.parse(trimmed);
|
|
249
|
+
const events = trimmed.split(/\n\n+/).flatMap((block) => {
|
|
250
|
+
const data = block.split(`
|
|
251
|
+
`).filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trimStart()).join(`
|
|
252
|
+
`);
|
|
253
|
+
return data ? [data] : [];
|
|
254
|
+
});
|
|
255
|
+
if (!events.length)
|
|
256
|
+
throw new UnbrowseError("Empty MCP response", 502, "mcp_empty");
|
|
257
|
+
return JSON.parse(events[events.length - 1]);
|
|
258
|
+
}
|
|
259
|
+
function resultText(result) {
|
|
260
|
+
const text = result.content?.filter((c) => c.type === "text" && typeof c.text === "string").map((c) => c.text).join(`
|
|
261
|
+
`);
|
|
262
|
+
return text || (result.structuredContent === undefined ? "" : JSON.stringify(result.structuredContent));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
class UnbrowseMcp {
|
|
266
|
+
url;
|
|
267
|
+
apiKey;
|
|
268
|
+
endUser;
|
|
269
|
+
client;
|
|
270
|
+
fetchImpl;
|
|
271
|
+
nextId = 1;
|
|
272
|
+
constructor(opts = {}) {
|
|
273
|
+
this.url = (opts.url ?? env2("UNBROWSE_MCP_URL") ?? DEFAULT_MCP_URL).replace(/\/+$/, "");
|
|
274
|
+
this.apiKey = opts.apiKey ?? env2("UNBROWSE_API_KEY");
|
|
275
|
+
this.endUser = opts.endUser ?? env2("UNBROWSE_END_USER");
|
|
276
|
+
this.client = opts.client ?? "sdk";
|
|
277
|
+
this.fetchImpl = opts.fetch ?? ((...a) => globalThis.fetch(...a));
|
|
278
|
+
}
|
|
279
|
+
async rpc(method, params) {
|
|
280
|
+
const res = await this.fetchImpl(this.url, {
|
|
281
|
+
method: "POST",
|
|
282
|
+
headers: {
|
|
283
|
+
...this.apiKey ? { authorization: `Bearer ${this.apiKey}` } : {},
|
|
284
|
+
...this.endUser ? { "x-unbrowse-end-user": this.endUser } : {},
|
|
285
|
+
"content-type": "application/json",
|
|
286
|
+
accept: "application/json, text/event-stream",
|
|
287
|
+
"mcp-protocol-version": MCP_PROTOCOL_VERSION,
|
|
288
|
+
"user-agent": `unbrowse-${this.client}`
|
|
289
|
+
},
|
|
290
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: this.nextId++, method, ...params ? { params } : {} })
|
|
291
|
+
});
|
|
292
|
+
const text = await res.text();
|
|
293
|
+
if (!res.ok) {
|
|
294
|
+
let body = {};
|
|
295
|
+
try {
|
|
296
|
+
body = JSON.parse(text);
|
|
297
|
+
} catch {}
|
|
298
|
+
const err = typeof body.error === "object" ? body.error : undefined;
|
|
299
|
+
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);
|
|
300
|
+
}
|
|
301
|
+
const msg = parseRpcBody(text);
|
|
302
|
+
if (msg.error)
|
|
303
|
+
throw new UnbrowseError(msg.error.message, 200, msg.error.data?.code ?? `rpc_${msg.error.code}`, msg.error);
|
|
304
|
+
return msg.result;
|
|
305
|
+
}
|
|
306
|
+
async listTools() {
|
|
307
|
+
const tools = [];
|
|
308
|
+
let cursor;
|
|
309
|
+
do {
|
|
310
|
+
const page = await this.rpc("tools/list", cursor ? { cursor } : undefined);
|
|
311
|
+
tools.push(...page.tools);
|
|
312
|
+
cursor = page.nextCursor;
|
|
313
|
+
} while (cursor);
|
|
314
|
+
return tools;
|
|
315
|
+
}
|
|
316
|
+
callTool(name, args = {}) {
|
|
317
|
+
return this.rpc("tools/call", { name, arguments: args });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
178
320
|
export {
|
|
179
321
|
DEFAULT_BASE_URL,
|
|
322
|
+
DEFAULT_MCP_URL,
|
|
323
|
+
MCP_PROTOCOL_VERSION,
|
|
180
324
|
Unbrowse,
|
|
181
325
|
UnbrowseError,
|
|
326
|
+
UnbrowseMcp,
|
|
182
327
|
cursorInstallLink,
|
|
328
|
+
isEgressStep,
|
|
183
329
|
mcpCommands,
|
|
184
330
|
normalizeHost,
|
|
331
|
+
parseRpcBody,
|
|
332
|
+
resultText,
|
|
185
333
|
vscodeInstallLink
|
|
186
334
|
};
|
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Json } from "./types.ts";
|
|
2
|
+
export declare const DEFAULT_MCP_URL = "https://unbrowse.ai/mcp";
|
|
3
|
+
/** Sent as `Mcp-Protocol-Version`. The hosted server refuses 2025-06-18 on its streaming path. */
|
|
4
|
+
export declare const MCP_PROTOCOL_VERSION = "2025-11-25";
|
|
5
|
+
export type McpOptions = {
|
|
6
|
+
/** API key (`ub_live_…`) or OAuth access token. Defaults to `UNBROWSE_API_KEY`. */
|
|
7
|
+
apiKey?: string;
|
|
8
|
+
/** Defaults to `UNBROWSE_MCP_URL`, then https://unbrowse.ai/mcp. */
|
|
9
|
+
url?: string;
|
|
10
|
+
/** Org keys: the end user the call is for (`X-Unbrowse-End-User`). */
|
|
11
|
+
endUser?: string;
|
|
12
|
+
fetch?: typeof globalThis.fetch;
|
|
13
|
+
/** Client name sent on the wire (User-Agent suffix). */
|
|
14
|
+
client?: string;
|
|
15
|
+
};
|
|
16
|
+
export type McpTool = {
|
|
17
|
+
name: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
inputSchema: {
|
|
20
|
+
type: "object";
|
|
21
|
+
properties?: Record<string, Json>;
|
|
22
|
+
required?: string[];
|
|
23
|
+
} & Record<string, unknown>;
|
|
24
|
+
};
|
|
25
|
+
export type McpContent = {
|
|
26
|
+
type: string;
|
|
27
|
+
text?: string;
|
|
28
|
+
} & Record<string, unknown>;
|
|
29
|
+
export type McpToolResult = {
|
|
30
|
+
content: McpContent[];
|
|
31
|
+
structuredContent?: Json;
|
|
32
|
+
isError?: boolean;
|
|
33
|
+
};
|
|
34
|
+
/** The JSON-RPC message in a response body: plain JSON, or the last `data:` event of an SSE stream. */
|
|
35
|
+
export declare function parseRpcBody(text: string): {
|
|
36
|
+
result?: unknown;
|
|
37
|
+
error?: {
|
|
38
|
+
code: number;
|
|
39
|
+
message: string;
|
|
40
|
+
data?: {
|
|
41
|
+
code?: string;
|
|
42
|
+
} & Record<string, unknown>;
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
/** Plain text of a tool result: its text parts, else its structured content as JSON. */
|
|
46
|
+
export declare function resultText(result: McpToolResult): string;
|
|
47
|
+
export declare class UnbrowseMcp {
|
|
48
|
+
readonly url: string;
|
|
49
|
+
private apiKey?;
|
|
50
|
+
private endUser?;
|
|
51
|
+
private client;
|
|
52
|
+
private fetchImpl;
|
|
53
|
+
private nextId;
|
|
54
|
+
constructor(opts?: McpOptions);
|
|
55
|
+
/** One JSON-RPC call. MCP errors become UnbrowseError with the server's `data.code` (e.g. `browser_capacity`). */
|
|
56
|
+
rpc<T>(method: string, params?: Record<string, unknown>): Promise<T>;
|
|
57
|
+
/** Every tool this key can call: the core tools plus the workspace's learned and indexed ones. */
|
|
58
|
+
listTools(): Promise<McpTool[]>;
|
|
59
|
+
callTool(name: string, args?: Record<string, unknown>): Promise<McpToolResult>;
|
|
60
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -55,6 +55,39 @@ export type RunRequest = {
|
|
|
55
55
|
maxOperations?: number;
|
|
56
56
|
};
|
|
57
57
|
executionPlane?: DeploymentProfile;
|
|
58
|
+
/** `client`: you send the site requests from your own IP (see `Unbrowse.runOnClient`). Default `server`. */
|
|
59
|
+
egress?: "server" | "client";
|
|
60
|
+
};
|
|
61
|
+
/** A site request for you to send, in a client-egress run. */
|
|
62
|
+
export type EgressRequest = {
|
|
63
|
+
/** Answer with this as `requestId`. */
|
|
64
|
+
id: string;
|
|
65
|
+
method: string;
|
|
66
|
+
url: string;
|
|
67
|
+
headers: Record<string, string>;
|
|
68
|
+
body?: string;
|
|
69
|
+
bodyEncoding?: "text" | "base64";
|
|
70
|
+
/** `manual`: do not follow redirects; return the 3xx as it came. */
|
|
71
|
+
redirect: "follow" | "manual";
|
|
72
|
+
/** The same request as a curl command. */
|
|
73
|
+
curl: string;
|
|
74
|
+
};
|
|
75
|
+
/** A client-egress run waiting for you to send its next site request(s). */
|
|
76
|
+
export type EgressStep = {
|
|
77
|
+
status: "egress_required";
|
|
78
|
+
egressId: string;
|
|
79
|
+
requests: EgressRequest[];
|
|
80
|
+
};
|
|
81
|
+
/** The site's response to one request, as you got it. */
|
|
82
|
+
export type EgressResponse = {
|
|
83
|
+
status: number;
|
|
84
|
+
/** `[name, value]` pairs keep repeated headers (set-cookie). */
|
|
85
|
+
headers?: [string, string][] | Record<string, string | string[]>;
|
|
86
|
+
/** Decoded body (after gzip/brotli): text, or base64 with `bodyEncoding: "base64"`. Up to 10 MB. */
|
|
87
|
+
body?: string;
|
|
88
|
+
bodyEncoding?: "text" | "base64";
|
|
89
|
+
/** The final URL, if you followed redirects. */
|
|
90
|
+
url?: string;
|
|
58
91
|
};
|
|
59
92
|
export type RunView = {
|
|
60
93
|
runId: string;
|
package/package.json
CHANGED