@solarisdk/mcp 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/browser.d.ts +4 -0
- package/dist/browser.js +216 -76
- package/dist/http.js +123 -43
- package/dist/server.d.ts +13 -0
- package/dist/server.js +111 -18
- package/dist/solari-mcp-http.bundle.cjs +114735 -0
- package/dist/solari-mcp.bundle.cjs +113219 -0
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -86,6 +86,7 @@ Chat, Code and Cowork alike.
|
|
|
86
86
|
| `SOLARI_API_KEY` | ✅ | — | unified `slr_live_…` key (works on both gateways) |
|
|
87
87
|
| `SOLARI_BASE_URL` | | `https://api.getsolari.com` | sandbox/desktop gateway |
|
|
88
88
|
| `SOLARI_BROWSER_URL` | | `SOLARI_BASE_URL` → prod | cloud-browser gateway (differs from `SOLARI_BASE_URL` on staging) |
|
|
89
|
+
| `SOLARI_BROWSER_API_KEY` | | `SOLARI_API_KEY` | separate key for the browser gateway, for environments where the browser and desktop key stores are split |
|
|
89
90
|
|
|
90
91
|
## Tools
|
|
91
92
|
|
package/dist/browser.d.ts
CHANGED
|
@@ -31,9 +31,13 @@ export interface BrowserRegistry {
|
|
|
31
31
|
sessions: Map<string, BrowserEntry>;
|
|
32
32
|
}
|
|
33
33
|
declare function api(cfg: BrowserConfig, method: string, path: string, body?: unknown): Promise<Response>;
|
|
34
|
+
/** Release a browser session gateway-side. Safe to call for unknown ids. */
|
|
35
|
+
export declare function releaseBrowserSession(cfg: BrowserConfig, id: string, fetchApi?: typeof api): Promise<void>;
|
|
34
36
|
export interface BrowserDeps {
|
|
35
37
|
connect: (cdpEndpoint: string) => Promise<Browser>;
|
|
36
38
|
fetchApi: typeof api;
|
|
37
39
|
}
|
|
38
40
|
export declare function makeBrowserToolset(cfg: BrowserConfig, reg: BrowserRegistry, deps?: BrowserDeps): Record<string, Tool>;
|
|
41
|
+
/** Release every browser session in a registry (used on eviction/shutdown). */
|
|
42
|
+
export declare function releaseAllBrowserSessions(cfg: BrowserConfig, reg: BrowserRegistry, fetchApi?: typeof api): Promise<void>;
|
|
39
43
|
export {};
|
package/dist/browser.js
CHANGED
|
@@ -10,6 +10,10 @@ const text = (o) => ({
|
|
|
10
10
|
content: [{ type: "text", text: typeof o === "string" ? o : JSON.stringify(o, null, 2) }],
|
|
11
11
|
});
|
|
12
12
|
const MAX_PAGE_TEXT = 30_000;
|
|
13
|
+
const MAX_LINKS = 200;
|
|
14
|
+
const CDP_DIAL_ATTEMPTS = 3;
|
|
15
|
+
/** Signed ws/wss capability URLs must never reach the model or a log. */
|
|
16
|
+
const redact = (s) => s.replace(/wss?:\/\/[^\s"']+/gi, "[redacted-ws-url]");
|
|
13
17
|
async function api(cfg, method, path, body) {
|
|
14
18
|
// Session create can block up to 60s gateway-side waiting for a slot.
|
|
15
19
|
const res = await fetch(`${cfg.baseUrl}${path}`, {
|
|
@@ -23,20 +27,64 @@ async function api(cfg, method, path, body) {
|
|
|
23
27
|
});
|
|
24
28
|
return res;
|
|
25
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Turn a gateway error into a model-safe message: only the documented
|
|
32
|
+
* {code,message} fields, never the raw body (which on a 2xx-shaped response
|
|
33
|
+
* carries the session's bearer-capability ws URLs).
|
|
34
|
+
*/
|
|
26
35
|
async function apiError(res, what) {
|
|
27
36
|
let detail = "";
|
|
28
37
|
try {
|
|
29
|
-
|
|
38
|
+
const raw = await res.text();
|
|
39
|
+
try {
|
|
40
|
+
const j = JSON.parse(raw);
|
|
41
|
+
detail = [j.code, j.error ?? j.message].filter(Boolean).join(": ");
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
detail = redact(raw).slice(0, 200);
|
|
45
|
+
}
|
|
30
46
|
}
|
|
31
47
|
catch {
|
|
32
48
|
/* body unavailable */
|
|
33
49
|
}
|
|
34
|
-
throw new Error(`${what} failed: HTTP ${res.status} ${detail
|
|
50
|
+
throw new Error(`${what} failed: HTTP ${res.status}${detail ? ` ${detail}` : ""}`);
|
|
51
|
+
}
|
|
52
|
+
/** Release a browser session gateway-side. Safe to call for unknown ids. */
|
|
53
|
+
export async function releaseBrowserSession(cfg, id, fetchApi = api) {
|
|
54
|
+
const api_ = fetchApi;
|
|
55
|
+
// Gateway bearer verification can transiently 401 (control-plane verify blip
|
|
56
|
+
// / auth-cache churn) and a failed release leaks the pool slot until TTL, so
|
|
57
|
+
// retry transient statuses before giving up.
|
|
58
|
+
let res = await api_(cfg, "DELETE", `/sessions/${encodeURIComponent(id)}`);
|
|
59
|
+
for (let i = 0; i < 2 && (res.status === 401 || res.status === 429 || res.status >= 500); i++) {
|
|
60
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
61
|
+
res = await api_(cfg, "DELETE", `/sessions/${encodeURIComponent(id)}`);
|
|
62
|
+
}
|
|
63
|
+
if (res.status === 404) {
|
|
64
|
+
// Bare 404 = already gone (fine). 404 + InvalidSessionId = the gateway
|
|
65
|
+
// refused the id and released NOTHING.
|
|
66
|
+
let code;
|
|
67
|
+
try {
|
|
68
|
+
code = (await res.json()).code;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* no body */
|
|
72
|
+
}
|
|
73
|
+
if (code === "InvalidSessionId") {
|
|
74
|
+
throw new Error(`release failed: gateway rejected sessionId (session may still be live)`);
|
|
75
|
+
}
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (res.status !== 204 && !res.ok)
|
|
79
|
+
await apiError(res, "session release");
|
|
35
80
|
}
|
|
36
81
|
const defaultDeps = {
|
|
37
82
|
connect: (cdpEndpoint) => puppeteer.connect({
|
|
38
83
|
browserWSEndpoint: cdpEndpoint,
|
|
39
|
-
|
|
84
|
+
// null = adopt the pool browser's real window. Forcing a viewport here
|
|
85
|
+
// applies an Emulation override, which makes window/screen metrics
|
|
86
|
+
// inconsistent and weakens stealth sessions.
|
|
87
|
+
defaultViewport: null,
|
|
40
88
|
}),
|
|
41
89
|
fetchApi: api,
|
|
42
90
|
};
|
|
@@ -46,19 +94,40 @@ export function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
|
|
|
46
94
|
const e = reg.sessions.get(id);
|
|
47
95
|
if (!e)
|
|
48
96
|
throw new Error(`unknown browser sessionId: ${id} (create one with solari_browser_create)`);
|
|
97
|
+
// The signed CDP URL is not re-dialable after the session expires, so say
|
|
98
|
+
// so plainly instead of surfacing a raw "Target closed".
|
|
99
|
+
if (e.expiresAt && Date.parse(e.expiresAt) <= Date.now()) {
|
|
100
|
+
reg.sessions.delete(id);
|
|
101
|
+
throw new Error(`browser session ${id} expired at ${e.expiresAt}; create a new one with solari_browser_create`);
|
|
102
|
+
}
|
|
49
103
|
return e;
|
|
50
104
|
};
|
|
51
|
-
//
|
|
52
|
-
//
|
|
105
|
+
// Act on the newest attached, non-blank page so a click that opened a tab or
|
|
106
|
+
// popup is followed rather than silently ignored.
|
|
53
107
|
const activePage = async (e) => {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
108
|
+
let pages = [];
|
|
109
|
+
try {
|
|
110
|
+
pages = await e.browser.pages();
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
/* browser gone; fall through to the cached page */
|
|
114
|
+
}
|
|
115
|
+
const usable = pages.filter((p) => !p.isClosed());
|
|
116
|
+
if (usable.length === 0) {
|
|
117
|
+
if (!e.page.isClosed())
|
|
118
|
+
return e.page;
|
|
58
119
|
throw new Error("no open pages in this browser session");
|
|
59
|
-
|
|
120
|
+
}
|
|
121
|
+
const named = usable.filter((p) => {
|
|
122
|
+
const u = p.url();
|
|
123
|
+
return u && u !== "about:blank";
|
|
124
|
+
});
|
|
125
|
+
e.page = (named.length ? named : usable)[named.length ? named.length - 1 : usable.length - 1];
|
|
60
126
|
return e.page;
|
|
61
127
|
};
|
|
128
|
+
const capText = (s, what) => s.length > MAX_PAGE_TEXT
|
|
129
|
+
? `${s.slice(0, MAX_PAGE_TEXT)}\n…[truncated ${s.length - MAX_PAGE_TEXT} of ${s.length} ${what}]`
|
|
130
|
+
: s;
|
|
62
131
|
return {
|
|
63
132
|
solari_browser_create: {
|
|
64
133
|
description: "Start a Solari cloud browser session. Use this whenever you need to browse the web. " +
|
|
@@ -88,16 +157,44 @@ export function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
|
|
|
88
157
|
if (res.status !== 201)
|
|
89
158
|
await apiError(res, "session create");
|
|
90
159
|
const s = (await res.json());
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
160
|
+
// cdpEndpoint is optional on the wire; derive it from wsEndpoint the
|
|
161
|
+
// way the official SDK does.
|
|
162
|
+
const cdp = s.cdpEndpoint ?? s.wsEndpoint?.replace("/ws/", "/cdp/");
|
|
163
|
+
if (!cdp)
|
|
164
|
+
throw new Error("gateway returned no cdpEndpoint or wsEndpoint");
|
|
165
|
+
// From here on the session is BILLABLE. Any failure before it is
|
|
166
|
+
// registered must release it, or it leaks until TTL with an id the
|
|
167
|
+
// model never saw.
|
|
168
|
+
try {
|
|
169
|
+
let browser;
|
|
170
|
+
let lastErr;
|
|
171
|
+
for (let i = 0; i < CDP_DIAL_ATTEMPTS; i++) {
|
|
172
|
+
try {
|
|
173
|
+
browser = await deps.connect(cdp);
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
// A freshly-started session has a transient upstream window.
|
|
178
|
+
lastErr = err;
|
|
179
|
+
await new Promise((r) => setTimeout(r, 500 * (i + 1)));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (!browser)
|
|
183
|
+
throw lastErr ?? new Error("could not connect to the browser");
|
|
184
|
+
const pages = await browser.pages();
|
|
185
|
+
const page = pages.find((p) => !p.isClosed()) ?? (await browser.newPage());
|
|
186
|
+
reg.sessions.set(s.sessionId, {
|
|
187
|
+
sessionId: s.sessionId,
|
|
188
|
+
browser,
|
|
189
|
+
page,
|
|
190
|
+
expiresAt: s.expiresAt,
|
|
191
|
+
recording: Boolean(a.recording),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
await releaseBrowserSession(cfg, s.sessionId, api).catch(() => { });
|
|
196
|
+
throw new Error(`browser session started but could not be attached (released it): ${redact(err instanceof Error ? err.message : String(err))}`);
|
|
197
|
+
}
|
|
101
198
|
// Proxy silently degrades rather than erroring — surface what we got.
|
|
102
199
|
return text({
|
|
103
200
|
sessionId: s.sessionId,
|
|
@@ -111,13 +208,27 @@ export function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
|
|
|
111
208
|
inputSchema: { sessionId: z.string(), url: z.string() },
|
|
112
209
|
handler: async (a) => {
|
|
113
210
|
const page = await activePage(need(a.sessionId));
|
|
114
|
-
|
|
211
|
+
let url = a.url.trim();
|
|
212
|
+
if (!/^[a-z][a-z0-9+.-]*:/i.test(url))
|
|
213
|
+
url = `https://${url}`;
|
|
214
|
+
const scheme = url.slice(0, url.indexOf(":")).toLowerCase();
|
|
215
|
+
if (scheme !== "http" && scheme !== "https") {
|
|
216
|
+
throw new Error(`refusing to navigate to a non-http(s) URL (${scheme}:)`);
|
|
217
|
+
}
|
|
218
|
+
// Block link-local / cloud metadata so page content can't steer the
|
|
219
|
+
// agent into reading the browser pool's instance credentials.
|
|
220
|
+
const host = new URL(url).hostname;
|
|
221
|
+
if (/^(169\.254\.|127\.|\[?::1\]?$|localhost$|metadata\.google)/i.test(host)) {
|
|
222
|
+
throw new Error(`refusing to navigate to internal host ${host}`);
|
|
223
|
+
}
|
|
224
|
+
const resp = await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
|
115
225
|
return text({ url: page.url(), title: await page.title(), status: resp?.status() ?? null });
|
|
116
226
|
},
|
|
117
227
|
},
|
|
118
228
|
solari_browser_read_page: {
|
|
119
229
|
description: "Read the current page. format 'text' (default) returns visible text; 'links' returns " +
|
|
120
|
-
"clickable links {text, href}; 'html' returns
|
|
230
|
+
"clickable links {text, href}; 'html' returns HTML with scripts/styles stripped. " +
|
|
231
|
+
"Large output is truncated with an explicit marker.",
|
|
121
232
|
inputSchema: {
|
|
122
233
|
sessionId: z.string(),
|
|
123
234
|
format: z.enum(["text", "links", "html"]).optional(),
|
|
@@ -125,35 +236,45 @@ export function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
|
|
|
125
236
|
handler: async (a) => {
|
|
126
237
|
const page = await activePage(need(a.sessionId));
|
|
127
238
|
const fmt = a.format ?? "text";
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
out = await page.content();
|
|
131
|
-
}
|
|
132
|
-
else if (fmt === "links") {
|
|
133
|
-
const links = await page.$$eval("a[href]", (as) => as
|
|
239
|
+
if (fmt === "links") {
|
|
240
|
+
const all = await page.$$eval("a[href]", (as) => as
|
|
134
241
|
.map((el) => ({
|
|
135
242
|
text: (el.textContent ?? "").trim().slice(0, 120),
|
|
136
243
|
href: el.href,
|
|
137
244
|
}))
|
|
138
|
-
.filter((l) => l.text)
|
|
139
|
-
|
|
140
|
-
|
|
245
|
+
.filter((l) => l.text));
|
|
246
|
+
// Truncate whole elements — slicing the serialized JSON would hand
|
|
247
|
+
// the model unparseable output.
|
|
248
|
+
const shown = all.slice(0, MAX_LINKS);
|
|
249
|
+
return text({ url: page.url(), shown: shown.length, total: all.length, links: shown });
|
|
250
|
+
}
|
|
251
|
+
let out;
|
|
252
|
+
if (fmt === "html") {
|
|
253
|
+
out = await page.evaluate(() => {
|
|
254
|
+
const d = document.cloneNode(true);
|
|
255
|
+
d.querySelectorAll("script,style,noscript,svg").forEach((n) => n.remove());
|
|
256
|
+
return d.documentElement?.outerHTML ?? "";
|
|
257
|
+
});
|
|
141
258
|
}
|
|
142
259
|
else {
|
|
143
260
|
out = await page.evaluate(() => document.body?.innerText ?? "");
|
|
144
261
|
}
|
|
145
|
-
|
|
146
|
-
return text(`${page.url()}\n\n${out.slice(0, MAX_PAGE_TEXT)}${truncated ? "\n…[truncated]" : ""}`);
|
|
262
|
+
return text(`${page.url()}\n\n${capText(out, "chars")}`);
|
|
147
263
|
},
|
|
148
264
|
},
|
|
149
265
|
solari_browser_screenshot: {
|
|
150
|
-
description: "Screenshot the browser session's current page (JPEG
|
|
151
|
-
|
|
266
|
+
description: "Screenshot the browser session's current page (JPEG). fullPage captures the whole " +
|
|
267
|
+
"scrollable page (may be downscaled by the client if very tall).",
|
|
268
|
+
inputSchema: {
|
|
269
|
+
sessionId: z.string(),
|
|
270
|
+
fullPage: z.boolean().optional(),
|
|
271
|
+
quality: z.number().min(1).max(100).optional(),
|
|
272
|
+
},
|
|
152
273
|
handler: async (a) => {
|
|
153
274
|
const page = await activePage(need(a.sessionId));
|
|
154
275
|
const buf = await page.screenshot({
|
|
155
276
|
type: "jpeg",
|
|
156
|
-
quality: 75,
|
|
277
|
+
quality: a.quality ?? 75,
|
|
157
278
|
fullPage: Boolean(a.fullPage),
|
|
158
279
|
});
|
|
159
280
|
return {
|
|
@@ -186,17 +307,28 @@ export function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
|
|
|
186
307
|
},
|
|
187
308
|
},
|
|
188
309
|
solari_browser_type: {
|
|
189
|
-
description: "Type text into the page. Optionally focus a CSS selector first.
|
|
310
|
+
description: "Type text into the page. Optionally focus a CSS selector first. clear:true replaces the " +
|
|
311
|
+
"field's existing value (otherwise text is appended at the caret). pressEnter submits.",
|
|
190
312
|
inputSchema: {
|
|
191
313
|
sessionId: z.string(),
|
|
192
314
|
text: z.string(),
|
|
193
315
|
selector: z.string().optional(),
|
|
316
|
+
clear: z.boolean().optional(),
|
|
194
317
|
pressEnter: z.boolean().optional(),
|
|
195
318
|
},
|
|
196
319
|
handler: async (a) => {
|
|
197
320
|
const page = await activePage(need(a.sessionId));
|
|
198
|
-
if (a.selector)
|
|
199
|
-
|
|
321
|
+
if (a.selector) {
|
|
322
|
+
const sel = a.selector;
|
|
323
|
+
await page.focus(sel);
|
|
324
|
+
if (a.clear) {
|
|
325
|
+
await page.$eval(sel, (el) => {
|
|
326
|
+
const f = el;
|
|
327
|
+
f.value = "";
|
|
328
|
+
f.dispatchEvent(new Event("input", { bubbles: true }));
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
200
332
|
await page.keyboard.type(a.text, { delay: 20 });
|
|
201
333
|
if (a.pressEnter)
|
|
202
334
|
await page.keyboard.press("Enter");
|
|
@@ -204,12 +336,22 @@ export function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
|
|
|
204
336
|
},
|
|
205
337
|
},
|
|
206
338
|
solari_browser_key: {
|
|
207
|
-
description: "Press a key in the browser session (e.g. 'Enter', 'Escape', 'ArrowDown', 'PageDown')."
|
|
339
|
+
description: "Press a key in the browser session (e.g. 'Enter', 'Escape', 'ArrowDown', 'PageDown'). " +
|
|
340
|
+
"Chords use '+' (e.g. 'Control+a').",
|
|
208
341
|
inputSchema: { sessionId: z.string(), key: z.string() },
|
|
209
342
|
handler: async (a) => {
|
|
210
343
|
const page = await activePage(need(a.sessionId));
|
|
211
|
-
|
|
212
|
-
|
|
344
|
+
const parts = a.key.split("+").filter(Boolean);
|
|
345
|
+
const key = parts.pop();
|
|
346
|
+
for (const m of parts)
|
|
347
|
+
await page.keyboard.down(m);
|
|
348
|
+
try {
|
|
349
|
+
await page.keyboard.press(key);
|
|
350
|
+
}
|
|
351
|
+
finally {
|
|
352
|
+
for (const m of parts.reverse())
|
|
353
|
+
await page.keyboard.up(m);
|
|
354
|
+
}
|
|
213
355
|
return text({ ok: true });
|
|
214
356
|
},
|
|
215
357
|
},
|
|
@@ -218,17 +360,11 @@ export function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
|
|
|
218
360
|
inputSchema: { sessionId: z.string(), expression: z.string() },
|
|
219
361
|
handler: async (a) => {
|
|
220
362
|
const page = await activePage(need(a.sessionId));
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}
|
|
227
|
-
catch {
|
|
228
|
-
return String(v);
|
|
229
|
-
}
|
|
230
|
-
}, a.expression);
|
|
231
|
-
return text(result);
|
|
363
|
+
// Pass the expression as a string: puppeteer sends it as a
|
|
364
|
+
// debugger-originated Runtime.evaluate, which is exempt from the
|
|
365
|
+
// page's CSP. Wrapping it in eval() inside page context is not.
|
|
366
|
+
const result = await page.evaluate(a.expression);
|
|
367
|
+
return text(result === undefined ? "undefined" : result);
|
|
232
368
|
},
|
|
233
369
|
},
|
|
234
370
|
solari_browser_replay_url: {
|
|
@@ -237,6 +373,10 @@ export function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
|
|
|
237
373
|
inputSchema: { sessionId: z.string() },
|
|
238
374
|
handler: async (a) => {
|
|
239
375
|
const id = a.sessionId;
|
|
376
|
+
const known = reg.sessions.get(id);
|
|
377
|
+
if (known && !known.recording) {
|
|
378
|
+
throw new Error(`session ${id} was not created with recording: true, so it has no replay`);
|
|
379
|
+
}
|
|
240
380
|
const res = await api(cfg, "GET", `/sessions/${encodeURIComponent(id)}/replay-url`);
|
|
241
381
|
if (!res.ok)
|
|
242
382
|
await apiError(res, "replay-url");
|
|
@@ -248,6 +388,9 @@ export function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
|
|
|
248
388
|
inputSchema: { sessionId: z.string() },
|
|
249
389
|
handler: async (a) => {
|
|
250
390
|
const id = a.sessionId;
|
|
391
|
+
// Release FIRST: if it fails, keep the registry entry so the model can
|
|
392
|
+
// retry rather than losing the handle to a still-billing session.
|
|
393
|
+
await releaseBrowserSession(cfg, id, api);
|
|
251
394
|
const e = reg.sessions.get(id);
|
|
252
395
|
if (e) {
|
|
253
396
|
try {
|
|
@@ -258,31 +401,28 @@ export function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
|
|
|
258
401
|
}
|
|
259
402
|
reg.sessions.delete(id);
|
|
260
403
|
}
|
|
261
|
-
// Gateway bearer verification can transiently 401 (control-plane
|
|
262
|
-
// verify blip / auth-cache churn); a failed release leaks the pool
|
|
263
|
-
// slot until TTL, so retry once before surfacing the error.
|
|
264
|
-
let res = await api(cfg, "DELETE", `/sessions/${encodeURIComponent(id)}`);
|
|
265
|
-
if (res.status === 401) {
|
|
266
|
-
await new Promise((r) => setTimeout(r, 1500));
|
|
267
|
-
res = await api(cfg, "DELETE", `/sessions/${encodeURIComponent(id)}`);
|
|
268
|
-
}
|
|
269
|
-
// 204 = released; bare 404 = already gone (fine); 404 + InvalidSessionId = real failure.
|
|
270
|
-
if (res.status === 404) {
|
|
271
|
-
let code;
|
|
272
|
-
try {
|
|
273
|
-
code = (await res.json()).code;
|
|
274
|
-
}
|
|
275
|
-
catch {
|
|
276
|
-
/* no body */
|
|
277
|
-
}
|
|
278
|
-
if (code === "InvalidSessionId")
|
|
279
|
-
throw new Error("release failed: gateway rejected sessionId");
|
|
280
|
-
}
|
|
281
|
-
else if (res.status !== 204) {
|
|
282
|
-
await apiError(res, "session release");
|
|
283
|
-
}
|
|
284
404
|
return text({ ok: true });
|
|
285
405
|
},
|
|
286
406
|
},
|
|
287
407
|
};
|
|
288
408
|
}
|
|
409
|
+
/** Release every browser session in a registry (used on eviction/shutdown). */
|
|
410
|
+
export async function releaseAllBrowserSessions(cfg, reg, fetchApi = api) {
|
|
411
|
+
const ids = [...reg.sessions.keys()];
|
|
412
|
+
await Promise.all(ids.map(async (id) => {
|
|
413
|
+
const e = reg.sessions.get(id);
|
|
414
|
+
reg.sessions.delete(id);
|
|
415
|
+
try {
|
|
416
|
+
await releaseBrowserSession(cfg, id, fetchApi);
|
|
417
|
+
}
|
|
418
|
+
catch (err) {
|
|
419
|
+
console.error(`solari-mcp: failed to release browser session ${id}:`, err);
|
|
420
|
+
}
|
|
421
|
+
try {
|
|
422
|
+
await e?.browser.disconnect();
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
/* already gone */
|
|
426
|
+
}
|
|
427
|
+
}));
|
|
428
|
+
}
|