@solarisdk/mcp 0.4.3 → 0.4.4

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 CHANGED
@@ -187,7 +187,7 @@ cookie count and origins it stored, so they can see what was persisted.
187
187
  | `solari_read_file` / `solari_write_file` / `solari_list_files` | Guest filesystem |
188
188
  | `solari_get_preview_url` | Public preview URL for an in-guest port |
189
189
  | `solari_screenshot` | Capture the desktop as a PNG (image content) |
190
- | `solari_click` / `solari_type` / `solari_key` | Desktop mouse/keyboard |
190
+ | `solari_click` / `solari_type` / `solari_key` | Desktop mouse/keyboard (`solari_click` takes optional `doubleClick` + `button: left\|right\|middle`) |
191
191
  | `solari_open_app` | Launch an app on the desktop |
192
192
 
193
193
  (The desktop GUI tools require a `solari_desktop_create` session; they error on
@@ -0,0 +1,2 @@
1
+ import { type BrowserToolCtx, type Tool } from "./context.js";
2
+ export declare function makeAuthTools(ctx: BrowserToolCtx): Record<string, Tool>;
@@ -0,0 +1,403 @@
1
+ // Profiles + login: saved sign-in state, password-manager auto-login, and the
2
+ // human handoff flow (hot mid-session rescue + cold profile seeding).
3
+ import { z } from "zod";
4
+ import { apiError, HANDOFF_POLL_MS, redact, text } from "./context.js";
5
+ export function makeAuthTools(ctx) {
6
+ const { cfg, deps, api, need } = ctx;
7
+ return {
8
+ solari_browser_profiles: {
9
+ description: "List saved browser profiles for this account. A profile is a stored signed-in state " +
10
+ "(cookies + localStorage) that solari_browser_create({profileId}) replays, so the " +
11
+ "session starts already logged in.\n" +
12
+ "Check here FIRST when a task will need a login: if a profile for that site already " +
13
+ "exists, use it and no human is involved at all. `version` is 1 and `populated` is " +
14
+ "false for a profile nobody has signed into yet — that one still needs " +
15
+ "solari_browser_login({profileName}).",
16
+ inputSchema: {},
17
+ handler: async () => {
18
+ const res = await api(cfg, "GET", "/profiles");
19
+ if (res.status !== 200)
20
+ await apiError(res, "list profiles");
21
+ const rows = (await res.json());
22
+ return text({
23
+ profiles: rows.map((p) => ({
24
+ profileId: p.id,
25
+ name: p.name,
26
+ version: p.version,
27
+ populated: Boolean(p.storageStateS3Key),
28
+ lastUsedAt: p.lastUsedAt ?? null,
29
+ })),
30
+ });
31
+ },
32
+ },
33
+ solari_browser_autologin_status: {
34
+ description: "Check whether this account can sign in to websites automatically, and get a direct " +
35
+ "setup link if it cannot.\n" +
36
+ "Call this when a task will clearly need a login, BEFORE you start — if nothing is set " +
37
+ "up you can tell the user once, up front, with a link, instead of interrupting them " +
38
+ "mid-run. Also worth calling if a login keeps needing a human.\n" +
39
+ "Returns the connected password managers, the sites already configured, and `setupUrl`. " +
40
+ "If `configured` is false, SHOW setupUrl TO THE USER: it opens the page where they " +
41
+ "connect a password manager. You cannot do that step for them — it needs a credential " +
42
+ "that must never pass through you — but the link removes all the guesswork.",
43
+ inputSchema: {},
44
+ handler: async () => {
45
+ const res = await api(cfg, "GET", "/vault/status");
46
+ if (res.status === 501) {
47
+ return text({
48
+ configured: false,
49
+ available: false,
50
+ note: "Automatic sign-in is not available on this deployment.",
51
+ });
52
+ }
53
+ if (res.status !== 200)
54
+ await apiError(res, "auto-login status");
55
+ const d = (await res.json());
56
+ const managers = d.managers ?? [];
57
+ const sites = d.sites ?? [];
58
+ return text({
59
+ configured: managers.length > 0,
60
+ available: true,
61
+ managers: managers.map((m) => ({ name: m.name, provider: m.provider, vaults: m.vaults })),
62
+ sites,
63
+ setupUrl: d.setupUrl ?? null,
64
+ next: managers.length === 0
65
+ ? "No password manager is connected. Show setupUrl to the user — it takes them " +
66
+ "straight to the page where they connect one, after which logins happen with no " +
67
+ "human involved."
68
+ : sites.length === 0
69
+ ? "A password manager is connected but no sites use it yet. Add one with " +
70
+ "solari_browser_autologin_site, or the user can do it at setupUrl."
71
+ : "Automatic sign-in is set up. Sessions will log in to these sites on their own.",
72
+ });
73
+ },
74
+ },
75
+ solari_browser_autologin_site: {
76
+ description: "Tell Solari to sign in to a site automatically using a connected password manager.\n" +
77
+ "`domain` is the site, e.g. 'github.com'. `manager` is the NAME of a connected password " +
78
+ "manager (see solari_browser_autologin_status) — omit it to mean 'remember the session " +
79
+ "but ask a human for a fresh login'. `item` is an optional 'Vault/Item' path for when " +
80
+ "one site has several logins; omit it to match by site.\n" +
81
+ "This only says WHICH credential to use — it never handles the credential itself.",
82
+ inputSchema: {
83
+ domain: z.string(),
84
+ manager: z.string().optional(),
85
+ item: z.string().optional(),
86
+ },
87
+ handler: async (a) => {
88
+ const res = await api(cfg, "POST", "/vault/sites", {
89
+ domain: a.domain,
90
+ ...(typeof a.manager === "string" ? { manager: a.manager } : {}),
91
+ ...(typeof a.item === "string" ? { item: a.item } : {}),
92
+ });
93
+ if (res.status !== 200)
94
+ await apiError(res, "configure auto-login site");
95
+ const d = (await res.json());
96
+ return text({
97
+ ...d,
98
+ next: "Set. The next session that hits a login wall on this site signs in automatically " +
99
+ "if the credential is found; otherwise a human is asked, as before.",
100
+ });
101
+ },
102
+ },
103
+ solari_browser_login: {
104
+ description: "Get this session signed in. Call it the moment you hit a login form, a 2FA prompt, or "
105
+ + "anything asking for a password — you must never handle credentials yourself.\n"
106
+ + "It tries the account's connected password manager FIRST: if one is set up for this "
107
+ + "site, you are signed in automatically and NO human is involved — the reply says "
108
+ + 'signedIn:true and you simply carry on. Otherwise it falls back to asking a human.\n'
109
+ + "Two ways to call it:\n" +
110
+ "HOT — pass `sessionId` when you are ALREADY on a login wall mid-task. The user gets a " +
111
+ "live view of the exact page you are on and types into it; you resume on that same page.\n" +
112
+ "COLD — pass `profileName` when you know a task will need a login and no session is open " +
113
+ "yet. The user signs in once in a profile editor, and every later " +
114
+ "solari_browser_create({profileId}) starts already authenticated. Prefer this when you " +
115
+ "can: nobody has to be watching mid-run. The profile is created if it does not exist.\n" +
116
+ "Pass exactly one of the two. Either way, SHOW THE RETURNED URL TO THE USER, then call " +
117
+ "solari_browser_await_login.\n" +
118
+ "In the HOT case your access to that session is REVOKED while the handoff is open — " +
119
+ "deliberately, so you cannot observe what they type — and the link expires in 5 minutes. " +
120
+ "Cold links last longer and revoke nothing, because there is no session yet.\n" +
121
+ "`reason` is required and is shown to the user — say plainly which site is asking and " +
122
+ "what for, because they are being asked to type a password on your say-so.",
123
+ inputSchema: {
124
+ sessionId: z.string().optional(),
125
+ profileName: z.string().optional(),
126
+ reason: z.string(),
127
+ },
128
+ handler: async (a) => {
129
+ const sessionId = typeof a.sessionId === "string" ? a.sessionId : "";
130
+ const profileName = typeof a.profileName === "string" ? a.profileName.trim() : "";
131
+ if (Boolean(sessionId) === Boolean(profileName)) {
132
+ throw new Error("Pass exactly one of sessionId (rescue a live session) or profileName (seed a " +
133
+ "profile before you start).");
134
+ }
135
+ // ── COLD: seed a profile, no session involved ────────────────────
136
+ if (profileName) {
137
+ const listRes = await api(cfg, "GET", "/profiles");
138
+ if (listRes.status !== 200)
139
+ await apiError(listRes, "list profiles");
140
+ const rows = (await listRes.json());
141
+ const match = rows.find((p) => (p.name ?? "").toLowerCase() === profileName.toLowerCase());
142
+ let profileId = match?.id ?? "";
143
+ if (!profileId) {
144
+ const mk = await api(cfg, "POST", "/profiles", { name: profileName });
145
+ if (mk.status !== 200 && mk.status !== 201)
146
+ await apiError(mk, "create profile");
147
+ profileId = (await mk.json()).id ?? "";
148
+ if (!profileId)
149
+ throw new Error("profile created but no id returned");
150
+ }
151
+ const hRes = await api(cfg, "POST", `/profiles/${encodeURIComponent(profileId)}/login-handoff`, {
152
+ reason: a.reason,
153
+ });
154
+ if (hRes.status !== 200)
155
+ await apiError(hRes, "cold login request");
156
+ const h = (await hRes.json());
157
+ return text({
158
+ mode: "cold",
159
+ profileId,
160
+ profileName,
161
+ handoffId: h.handoffId,
162
+ url: h.url,
163
+ expiresAt: h.expiresAt,
164
+ // Echoed so await_login can tell a fresh save from the state it
165
+ // started in — the version bump IS the completion signal.
166
+ sinceVersion: h.version,
167
+ next: `Show the url to the user, then call solari_browser_await_login({ profileId: ` +
168
+ `"${profileId}", sinceVersion: ${h.version} }).`,
169
+ });
170
+ }
171
+ // ── HOT: rescue the live session ─────────────────────────────────
172
+ const e = need(sessionId);
173
+ // Try the account's password manager FIRST. If a vault is connected and
174
+ // this site is configured, the gateway signs in on its own and no human
175
+ // is involved at all — we never see the credential either way. Only
176
+ // escalate to a human when that cannot work. Best-effort: any failure
177
+ // here just falls through to the handoff below, which is the behaviour
178
+ // that existed before.
179
+ let setupUrl = null;
180
+ try {
181
+ const auto = await api(cfg, "POST", `/sessions/${encodeURIComponent(sessionId)}/autologin`, {});
182
+ if (auto.status === 200) {
183
+ const r = (await auto.json());
184
+ if (r.outcome === "already_authenticated" || r.outcome === "vault_login") {
185
+ return text({
186
+ mode: "auto",
187
+ signedIn: true,
188
+ via: r.outcome === "vault_login" ? "password manager" : "saved session",
189
+ next: "You are signed in — carry on. No human was needed.",
190
+ });
191
+ }
192
+ // The chain could not sign in and ALREADY OPENED a handoff for us.
193
+ // Falling through here would POST /handoff for a session that now
194
+ // has one open, which the gateway correctly refuses with 409
195
+ // HandoffAlreadyOpen -- and the url we were just handed would be
196
+ // thrown away, leaving an open handoff that nobody can reach and a
197
+ // session frozen until it expires. Use what we were given.
198
+ if (r.outcome === "human_required" && r.handoffUrl) {
199
+ try {
200
+ await e.browser.close();
201
+ }
202
+ catch {
203
+ /* the gateway is severing this socket anyway */
204
+ }
205
+ return text({
206
+ mode: "hot",
207
+ handoffId: r.handoffId,
208
+ url: r.handoffUrl,
209
+ // Why the password manager could not do this unaided. Surfaced
210
+ // because otherwise a mis-configured site is indistinguishable
211
+ // from an unsupported one.
212
+ ...(r.reason ? { autoLoginFailed: r.reason } : {}),
213
+ ...(r.setupRequired && r.setupUrl ? { setupUrl: r.setupUrl } : {}),
214
+ next: "Show the url to the user, then call solari_browser_await_login. " +
215
+ "Your access to this session is revoked until they finish.",
216
+ });
217
+ }
218
+ // Nothing is configured for this site. Remember the setup link so
219
+ // it can be offered alongside the handoff below — the user is
220
+ // already being interrupted, so this is the cheapest possible
221
+ // moment to tell them how to stop being interrupted next time.
222
+ if (r.setupRequired && r.setupUrl)
223
+ setupUrl = r.setupUrl;
224
+ }
225
+ }
226
+ catch {
227
+ /* fall through to the human handoff */
228
+ }
229
+ const res = await api(cfg, "POST", `/sessions/${encodeURIComponent(sessionId)}/handoff`, { reason: a.reason });
230
+ if (res.status !== 200)
231
+ await apiError(res, "handoff request");
232
+ const h = (await res.json());
233
+ // The gateway is severing our socket right now. Drop the local handle
234
+ // rather than leave a half-dead Browser object that throws confusing
235
+ // Target-closed errors on every subsequent tool call.
236
+ try {
237
+ await e.browser.close();
238
+ }
239
+ catch {
240
+ /* already gone — that is the point */
241
+ }
242
+ // Prefer the short link when the gateway offers one — it is far easier
243
+ // to relay to someone on a phone. Fall back to the long URL for older
244
+ // gateways that do not return shortUrl yet.
245
+ const showUrl = h.shortUrl ?? h.url;
246
+ return text({
247
+ mode: "hot",
248
+ handoffId: h.handoffId,
249
+ url: showUrl,
250
+ fullUrl: h.url,
251
+ expiresAt: h.expiresAt,
252
+ // Offered only when the account has NOTHING configured for this site.
253
+ // The user is already being interrupted, so this is the cheapest
254
+ // moment to show them how to stop being interrupted next time.
255
+ ...(setupUrl
256
+ ? {
257
+ setupUrl,
258
+ tip: "This account has no password manager connected for this site, so a human is " +
259
+ "needed every time. Connecting one at the setupUrl lets future logins happen " +
260
+ "automatically. MENTION THIS TO THE USER along with the sign-in link.",
261
+ }
262
+ : {}),
263
+ next: "Show the url to the user, then call solari_browser_await_login.",
264
+ });
265
+ },
266
+ },
267
+ solari_browser_save_profile: {
268
+ description: "Save this session's signed-in state (cookies + localStorage) into an existing profile, " +
269
+ "so future sessions start already logged in and no human is needed again. Pass the " +
270
+ "profileId to overwrite.\n" +
271
+ "ONLY do this when the user has asked you to remember the login. It is deliberately not " +
272
+ "automatic: a saved profile holds live session cookies, which bypass 2FA — it is a " +
273
+ "credential store, and it should exist because someone chose it. If they have not said " +
274
+ "so, ask first.\n" +
275
+ "Returns what was captured (cookie count and origins) so the user can see what they " +
276
+ "just persisted.",
277
+ inputSchema: {
278
+ sessionId: z.string(),
279
+ profileId: z.string(),
280
+ },
281
+ handler: async (a) => {
282
+ need(a.sessionId);
283
+ const res = await api(cfg, "POST", `/sessions/${encodeURIComponent(a.sessionId)}/save-profile`, { profileId: a.profileId });
284
+ if (res.status !== 200)
285
+ await apiError(res, "save profile");
286
+ const body = (await res.json());
287
+ return text({
288
+ ...body,
289
+ note: "Future sessions can use this with solari_browser_create({profileId}).",
290
+ });
291
+ },
292
+ },
293
+ solari_browser_await_login: {
294
+ description: "Wait for the human to finish the sign-in you requested with solari_browser_login. " +
295
+ "Blocks until they are done, the link expires, or timeoutMs elapses. Returns only a " +
296
+ "status — never anything the user typed. On 'completed' the session is yours again, " +
297
+ "on the same page, still signed in, and you can carry on where you left off. On " +
298
+ "'expired' or 'timeout' the user did not finish: ask if they still want to, and call " +
299
+ "solari_browser_login again for a fresh link rather than retrying this.",
300
+ inputSchema: {
301
+ sessionId: z.string().optional(),
302
+ profileId: z.string().optional(),
303
+ sinceVersion: z.number().optional(),
304
+ handoffId: z.string().optional(),
305
+ timeoutMs: z.number().optional(),
306
+ },
307
+ handler: async (a) => {
308
+ const sessionId = typeof a.sessionId === "string" ? a.sessionId : "";
309
+ const profileId = typeof a.profileId === "string" ? a.profileId : "";
310
+ if (Boolean(sessionId) === Boolean(profileId)) {
311
+ throw new Error("Pass exactly one of sessionId (hot handoff) or profileId (cold handoff) — the " +
312
+ "same one solari_browser_login returned.");
313
+ }
314
+ // Clamp: never spin forever, never poll so briefly the human has no
315
+ // chance. Defaults to the handoff's own 5-minute life.
316
+ const requested = typeof a.timeoutMs === "number" ? a.timeoutMs : 300_000;
317
+ const deadline = Date.now() + Math.min(Math.max(requested, 5_000), 600_000);
318
+ let status = "pending";
319
+ // ── COLD: watch the profile's version, which the editor's save bumps.
320
+ // Deliberately NOT "did a storageState appear": re-signing into a
321
+ // profile that already had one must also count as completed.
322
+ if (profileId) {
323
+ const since = typeof a.sinceVersion === "number" ? a.sinceVersion : 0;
324
+ let version = since;
325
+ while (Date.now() < deadline) {
326
+ const res = await api(cfg, "GET", "/profiles");
327
+ if (res.status !== 200)
328
+ await apiError(res, "profile status");
329
+ const rows = (await res.json());
330
+ const row = rows.find((p) => p.id === profileId);
331
+ if (!row)
332
+ throw new Error(`profile ${profileId} no longer exists`);
333
+ version = typeof row.version === "number" ? row.version : since;
334
+ if (version > since) {
335
+ status = "completed";
336
+ break;
337
+ }
338
+ await new Promise((r) => setTimeout(r, HANDOFF_POLL_MS));
339
+ }
340
+ if (status !== "completed")
341
+ status = "timeout";
342
+ return text({
343
+ mode: "cold",
344
+ status,
345
+ profileId,
346
+ version,
347
+ next: status === "completed"
348
+ ? `Signed in and saved. Use solari_browser_create({ profileId: "${profileId}" }) ` +
349
+ "and you will start already authenticated."
350
+ : "The user did not finish. Ask if they still want to, then call " +
351
+ "solari_browser_login again for a fresh link.",
352
+ });
353
+ }
354
+ const e = need(sessionId);
355
+ while (Date.now() < deadline) {
356
+ const q = typeof a.handoffId === "string" && a.handoffId
357
+ ? `?handoffId=${encodeURIComponent(a.handoffId)}`
358
+ : "";
359
+ const res = await api(cfg, "GET", `/sessions/${encodeURIComponent(sessionId)}/handoff${q}`);
360
+ if (res.status !== 200)
361
+ await apiError(res, "handoff status");
362
+ const body = (await res.json());
363
+ status = body.status ?? "none";
364
+ // "none" means no open handoff and no id to look up — treat as done
365
+ // rather than spinning; the caller can re-mint if that was wrong.
366
+ if (status !== "pending")
367
+ break;
368
+ await new Promise((r) => setTimeout(r, HANDOFF_POLL_MS));
369
+ }
370
+ if (status === "pending")
371
+ status = "timeout";
372
+ // Reconnect: the handoff severed our old socket on purpose, so the
373
+ // stored handle is dead even on success. Re-dial and re-adopt the page
374
+ // the human left us on, which is the whole point of a HOT handoff —
375
+ // the agent resumes mid-flow instead of starting over.
376
+ let resumed = false;
377
+ if (status === "completed" || status === "none") {
378
+ try {
379
+ const browser = await deps.connect(e.cdpEndpoint);
380
+ const pages = await browser.pages();
381
+ const page = pages.find((p) => !p.isClosed()) ?? (await browser.newPage());
382
+ e.browser = browser;
383
+ e.page = page;
384
+ resumed = true;
385
+ }
386
+ catch (err) {
387
+ return text({
388
+ status,
389
+ resumed: false,
390
+ note: "the sign-in finished but the session could not be re-attached: " +
391
+ redact(err instanceof Error ? err.message : String(err)),
392
+ });
393
+ }
394
+ }
395
+ return text({
396
+ status,
397
+ resumed,
398
+ ...(resumed ? { url: e.page.url() } : {}),
399
+ });
400
+ },
401
+ },
402
+ };
403
+ }
@@ -0,0 +1,2 @@
1
+ import { type BrowserToolCtx, type Tool } from "./context.js";
2
+ export declare function makeCaptureTools(ctx: BrowserToolCtx): Record<string, Tool>;
@@ -0,0 +1,67 @@
1
+ // Reading/screenshotting the current page.
2
+ import { z } from "zod";
3
+ import { MAX_LINKS, text } from "./context.js";
4
+ export function makeCaptureTools(ctx) {
5
+ const { need, activePage, capText } = ctx;
6
+ return {
7
+ solari_browser_read_page: {
8
+ description: "Read the current page. format 'text' (default) returns visible text; 'links' returns " +
9
+ "clickable links {text, href}; 'html' returns HTML with scripts/styles stripped. " +
10
+ "Large output is truncated with an explicit marker.",
11
+ inputSchema: {
12
+ sessionId: z.string(),
13
+ format: z.enum(["text", "links", "html"]).optional(),
14
+ },
15
+ handler: async (a) => {
16
+ const page = await activePage(need(a.sessionId));
17
+ const fmt = a.format ?? "text";
18
+ if (fmt === "links") {
19
+ const all = await page.$$eval("a[href]", (as) => as
20
+ .map((el) => ({
21
+ text: (el.textContent ?? "").trim().slice(0, 120),
22
+ href: el.href,
23
+ }))
24
+ .filter((l) => l.text));
25
+ // Truncate whole elements — slicing the serialized JSON would hand
26
+ // the model unparseable output.
27
+ const shown = all.slice(0, MAX_LINKS);
28
+ return text({ url: page.url(), shown: shown.length, total: all.length, links: shown });
29
+ }
30
+ let out;
31
+ if (fmt === "html") {
32
+ out = await page.evaluate(() => {
33
+ const d = document.cloneNode(true);
34
+ d.querySelectorAll("script,style,noscript,svg").forEach((n) => n.remove());
35
+ return d.documentElement?.outerHTML ?? "";
36
+ });
37
+ }
38
+ else {
39
+ out = await page.evaluate(() => document.body?.innerText ?? "");
40
+ }
41
+ return text(`${page.url()}\n\n${capText(out, "chars")}`);
42
+ },
43
+ },
44
+ solari_browser_screenshot: {
45
+ description: "Screenshot the browser session's current page (JPEG). fullPage captures the whole " +
46
+ "scrollable page (may be downscaled by the client if very tall).",
47
+ inputSchema: {
48
+ sessionId: z.string(),
49
+ fullPage: z.boolean().optional(),
50
+ quality: z.number().min(1).max(100).optional(),
51
+ },
52
+ handler: async (a) => {
53
+ const page = await activePage(need(a.sessionId));
54
+ const buf = await page.screenshot({
55
+ type: "jpeg",
56
+ quality: a.quality ?? 75,
57
+ fullPage: Boolean(a.fullPage),
58
+ });
59
+ return {
60
+ content: [
61
+ { type: "image", data: Buffer.from(buf).toString("base64"), mimeType: "image/jpeg" },
62
+ ],
63
+ };
64
+ },
65
+ },
66
+ };
67
+ }
@@ -0,0 +1,79 @@
1
+ import type { Browser, Page } from "puppeteer-core";
2
+ import type { ZodTypeAny } from "zod";
3
+ export interface McpResult {
4
+ content: Array<{
5
+ type: "text";
6
+ text: string;
7
+ } | {
8
+ type: "image";
9
+ data: string;
10
+ mimeType: string;
11
+ }>;
12
+ isError?: boolean;
13
+ }
14
+ export interface Tool {
15
+ description: string;
16
+ inputSchema: Record<string, ZodTypeAny>;
17
+ handler: (args: Record<string, unknown>) => Promise<McpResult>;
18
+ }
19
+ export interface BrowserConfig {
20
+ apiKey: string;
21
+ baseUrl: string;
22
+ }
23
+ export interface BrowserEntry {
24
+ sessionId: string;
25
+ browser: Browser;
26
+ page: Page;
27
+ expiresAt: string;
28
+ recording: boolean;
29
+ /**
30
+ * The session's raw CDP endpoint, kept so the handoff flow can RECONNECT.
31
+ *
32
+ * A login handoff severs the agent's socket gateway-side — deliberately, so
33
+ * the agent cannot watch the human type. That kills this entry's puppeteer
34
+ * handle, so resuming afterwards needs a fresh connect rather than the dead
35
+ * one. Without this the agent "resumes" into a Target-closed error.
36
+ */
37
+ cdpEndpoint: string;
38
+ }
39
+ export interface BrowserRegistry {
40
+ sessions: Map<string, BrowserEntry>;
41
+ }
42
+ export declare const text: (o: unknown) => McpResult;
43
+ export declare const MAX_PAGE_TEXT = 30000;
44
+ export declare const MAX_LINKS = 200;
45
+ export declare const CDP_DIAL_ATTEMPTS = 3;
46
+ /** How often await_login re-checks handoff status. */
47
+ export declare const HANDOFF_POLL_MS = 2000;
48
+ /** Signed ws/wss capability URLs must never reach the model or a log. */
49
+ export declare const redact: (s: string) => string;
50
+ export declare function api(cfg: BrowserConfig, method: string, path: string, body?: unknown): Promise<Response>;
51
+ /**
52
+ * Turn a gateway error into a model-safe message: only the documented
53
+ * {code,message} fields, never the raw body (which on a 2xx-shaped response
54
+ * carries the session's bearer-capability ws URLs).
55
+ */
56
+ export declare function apiError(res: Response, what: string): Promise<never>;
57
+ /** Release a browser session gateway-side. Safe to call for unknown ids. */
58
+ export declare function releaseBrowserSession(cfg: BrowserConfig, id: string, fetchApi?: typeof api): Promise<void>;
59
+ export interface BrowserDeps {
60
+ connect: (cdpEndpoint: string) => Promise<Browser>;
61
+ fetchApi: typeof api;
62
+ }
63
+ /**
64
+ * Everything a browser-tools/* capability module needs, gathered once by
65
+ * makeBrowserToolset and passed to each module's factory — rather than each
66
+ * module reaching back into browser.ts (which would close over its own
67
+ * `reg`/`deps`, duplicating state instead of sharing it).
68
+ */
69
+ export interface BrowserToolCtx {
70
+ cfg: BrowserConfig;
71
+ reg: BrowserRegistry;
72
+ deps: BrowserDeps;
73
+ /** deps.fetchApi, hoisted for convenience (matches the old local `api`). */
74
+ api: typeof api;
75
+ need: (id: string) => BrowserEntry;
76
+ /** The newest attached, non-blank page — see browser.ts for the rationale. */
77
+ activePage: (e: BrowserEntry) => Promise<Page>;
78
+ capText: (s: string, what: string) => string;
79
+ }
@@ -0,0 +1,74 @@
1
+ export const text = (o) => ({
2
+ content: [{ type: "text", text: typeof o === "string" ? o : JSON.stringify(o, null, 2) }],
3
+ });
4
+ export const MAX_PAGE_TEXT = 30_000;
5
+ export const MAX_LINKS = 200;
6
+ export const CDP_DIAL_ATTEMPTS = 3;
7
+ /** How often await_login re-checks handoff status. */
8
+ export const HANDOFF_POLL_MS = 2_000;
9
+ /** Signed ws/wss capability URLs must never reach the model or a log. */
10
+ export const redact = (s) => s.replace(/wss?:\/\/[^\s"']+/gi, "[redacted-ws-url]");
11
+ export async function api(cfg, method, path, body) {
12
+ // Session create can block up to 60s gateway-side waiting for a slot.
13
+ const res = await fetch(`${cfg.baseUrl}${path}`, {
14
+ method,
15
+ headers: {
16
+ authorization: `Bearer ${cfg.apiKey}`,
17
+ ...(body ? { "content-type": "application/json" } : {}),
18
+ },
19
+ ...(body ? { body: JSON.stringify(body) } : {}),
20
+ signal: AbortSignal.timeout(90_000),
21
+ });
22
+ return res;
23
+ }
24
+ /**
25
+ * Turn a gateway error into a model-safe message: only the documented
26
+ * {code,message} fields, never the raw body (which on a 2xx-shaped response
27
+ * carries the session's bearer-capability ws URLs).
28
+ */
29
+ export async function apiError(res, what) {
30
+ let detail = "";
31
+ try {
32
+ const raw = await res.text();
33
+ try {
34
+ const j = JSON.parse(raw);
35
+ detail = [j.code, j.error ?? j.message].filter(Boolean).join(": ");
36
+ }
37
+ catch {
38
+ detail = redact(raw).slice(0, 200);
39
+ }
40
+ }
41
+ catch {
42
+ /* body unavailable */
43
+ }
44
+ throw new Error(`${what} failed: HTTP ${res.status}${detail ? ` ${detail}` : ""}`);
45
+ }
46
+ /** Release a browser session gateway-side. Safe to call for unknown ids. */
47
+ export async function releaseBrowserSession(cfg, id, fetchApi = api) {
48
+ const api_ = fetchApi;
49
+ // Gateway bearer verification can transiently 401 (control-plane verify blip
50
+ // / auth-cache churn) and a failed release leaks the pool slot until TTL, so
51
+ // retry transient statuses before giving up.
52
+ let res = await api_(cfg, "DELETE", `/sessions/${encodeURIComponent(id)}`);
53
+ for (let i = 0; i < 2 && (res.status === 401 || res.status === 429 || res.status >= 500); i++) {
54
+ await new Promise((r) => setTimeout(r, 1500));
55
+ res = await api_(cfg, "DELETE", `/sessions/${encodeURIComponent(id)}`);
56
+ }
57
+ if (res.status === 404) {
58
+ // Bare 404 = already gone (fine). 404 + InvalidSessionId = the gateway
59
+ // refused the id and released NOTHING.
60
+ let code;
61
+ try {
62
+ code = (await res.json()).code;
63
+ }
64
+ catch {
65
+ /* no body */
66
+ }
67
+ if (code === "InvalidSessionId") {
68
+ throw new Error(`release failed: gateway rejected sessionId (session may still be live)`);
69
+ }
70
+ return;
71
+ }
72
+ if (res.status !== 204 && !res.ok)
73
+ await apiError(res, "session release");
74
+ }
@@ -0,0 +1,2 @@
1
+ import { type BrowserToolCtx, type Tool } from "./context.js";
2
+ export declare function makeInteractionTools(ctx: BrowserToolCtx): Record<string, Tool>;