@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 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
- detail = await res.text();
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.slice(0, 500)}`);
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
- defaultViewport: { width: 1280, height: 800 },
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
- // The agent may have navigated into a popup/new tab; act on the most
52
- // recently opened page that is still attached.
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
- if (!e.page.isClosed())
55
- return e.page;
56
- const pages = await e.browser.pages();
57
- if (pages.length === 0)
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
- e.page = pages[pages.length - 1];
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
- const browser = await deps.connect(s.cdpEndpoint);
92
- const pages = await browser.pages();
93
- const page = pages[0] ?? (await browser.newPage());
94
- reg.sessions.set(s.sessionId, {
95
- sessionId: s.sessionId,
96
- browser,
97
- page,
98
- expiresAt: s.expiresAt,
99
- recording: Boolean(a.recording),
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
- const resp = await page.goto(a.url, { waitUntil: "domcontentloaded", timeout: 60_000 });
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 raw HTML. Output is truncated to 30k chars.",
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
- let out;
129
- if (fmt === "html") {
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
- .slice(0, 200));
140
- out = JSON.stringify(links, null, 2);
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
- const truncated = out.length > MAX_PAGE_TEXT;
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 image).",
151
- inputSchema: { sessionId: z.string(), fullPage: z.boolean().optional() },
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. Set pressEnter to submit.",
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
- await page.click(a.selector);
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
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
212
- await page.keyboard.press(a.key);
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
- const result = await page.evaluate((expr) => {
222
- // eslint-disable-next-line no-eval
223
- const v = (0, eval)(expr);
224
- try {
225
- return JSON.parse(JSON.stringify(v ?? null));
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
+ }