@solarisdk/mcp 0.2.2 → 0.3.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 CHANGED
@@ -20,7 +20,8 @@ registry**: `*_create` tools return a `sessionId`, and the rest take it.
20
20
  Point any MCP client at the hosted Streamable HTTP endpoint with your API key:
21
21
 
22
22
  - **Prod:** `https://mcp.getsolari.com/mcp`
23
- - **Staging:** `https://mcp-sta.solaribrowser.com/mcp`
23
+ - **Staging:** currently NOT deployed — the us-east-1 ALB it rode was
24
+ removed in the us-west-1 migration and its DNS record has been deleted.
24
25
 
25
26
  Claude Desktop / claude.ai: **Settings → Connectors → Add custom connector**,
26
27
  URL as above, and send `Authorization: Bearer slr_live_…`. Or in
@@ -68,8 +69,9 @@ lifecycles — re-attach with `solari_connect`).
68
69
  "env": {
69
70
  "SOLARI_API_KEY": "slr_live_…"
70
71
  // optional overrides (defaults hit prod https://api.getsolari.com):
71
- // "SOLARI_BASE_URL": "https://desktops-sta.solaribrowser.com", // sandbox/desktop gateway
72
- // "SOLARI_BROWSER_URL": "https://api-sta.solaribrowser.com" // browser gateway
72
+ // optional staging override — ONE host now serves both gateways:
73
+ // "SOLARI_BASE_URL": "https://api-sta.getsolari.com",
74
+ // "SOLARI_BROWSER_URL": "https://api-sta.getsolari.com"
73
75
  }
74
76
  }
75
77
  }
@@ -85,7 +87,7 @@ Chat, Code and Cowork alike.
85
87
  |---|---|---|---|
86
88
  | `SOLARI_API_KEY` | ✅ | — | unified `slr_live_…` key (works on both gateways) |
87
89
  | `SOLARI_BASE_URL` | | `https://api.getsolari.com` | sandbox/desktop gateway |
88
- | `SOLARI_BROWSER_URL` | | `SOLARI_BASE_URL` → prod | cloud-browser gateway (differs from `SOLARI_BASE_URL` on staging) |
90
+ | `SOLARI_BROWSER_URL` | | `SOLARI_BASE_URL` → prod | cloud-browser gateway. Both prod (`api.getsolari.com`) and staging (`api-sta.getsolari.com`) now path-route browser AND desktop on one host, so this rarely needs setting |
89
91
  | `SOLARI_BROWSER_API_KEY` | | `SOLARI_API_KEY` | separate key for the browser gateway, for environments where the browser and desktop key stores are split |
90
92
 
91
93
  ## Tools
@@ -94,7 +96,7 @@ Chat, Code and Cowork alike.
94
96
 
95
97
  | Tool | What it does |
96
98
  |---|---|
97
- | `solari_browser_create` | Start a browser session (`stealth`, `proxy`, `captcha`, `recording`, `profileId`) → `sessionId` |
99
+ | `solari_browser_create` | Start a browser session (`mode`, `proxy`, `captcha`, `recording`, `profileId`) → `{sessionId, mode, captcha}` |
98
100
  | `solari_browser_navigate` | Go to a URL → `{url, title, status}` |
99
101
  | `solari_browser_read_page` | Page text / links / HTML (30k-char cap) |
100
102
  | `solari_browser_screenshot` | JPEG screenshot (image content) |
@@ -103,6 +105,39 @@ Chat, Code and Cowork alike.
103
105
  | `solari_browser_replay_url` | rrweb replay URL (needs `recording: true`) |
104
106
  | `solari_browser_close` | Release the session |
105
107
 
108
+ ### Browser mode — stealth by default
109
+
110
+ `solari_browser_create` defaults to **`mode: "stealth"`** (the anti-bot hardened pool). A bare
111
+ call with no arguments gets it; the model does not have to ask.
112
+
113
+ This used to be an optional `stealth: true` boolean, and in practice models did not opt in — so
114
+ sessions quietly landed on the fast pool and got blocked by bot detection. The failure looked
115
+ like "the site is broken" rather than "we chose the wrong pool", which is the worst way for a
116
+ default to be wrong.
117
+
118
+ `mode: "fast"` is the explicit opt-out: lower latency, for sites you already know do not
119
+ fingerprint or block automation (internal tools, localhost, your own app, plain docs). If a page
120
+ returns a block/captcha/challenge in fast mode, close the session and retry on stealth.
121
+
122
+ `proxy` and `captcha` are stealth-only upstream and are now **rejected** in fast mode rather than
123
+ silently dropped — a quietly unproxied request leaks the origin IP, which is the one thing the
124
+ caller was trying to avoid. The response echoes the resolved `mode`, so a later block is
125
+ actionable.
126
+
127
+ The deprecated `stealth` boolean still works in both directions for existing callers
128
+ (`stealth: false` still means fast); an explicit `mode` wins when both are supplied.
129
+
130
+ **Captcha auto-solving is on by default too**, for the same reason: an option a model has to opt
131
+ into does not get used, and the resulting failure ("the page is a challenge screen") reads as a
132
+ broken site rather than a missing flag. Pass **`captcha: false`** to opt out — solving costs money
133
+ and adds latency, so turn it off for sites you know never challenge.
134
+
135
+ The default *follows the pool* rather than being a flat `true`: captcha is stealth-only upstream,
136
+ so a flat default would make a plain `mode: "fast"` call throw on an option the caller never
137
+ asked for. Stealth ⇒ on, fast ⇒ off, explicit value always wins. Asking for `captcha: true`
138
+ *together with* `mode: "fast"` is a real contradiction and is refused. The response echoes the
139
+ resolved `captcha` alongside `mode`.
140
+
106
141
  ### Sandboxes + desktops
107
142
 
108
143
  | Tool | What it does |
@@ -137,11 +172,46 @@ npm run build:bundle # esbuild → dist/solari-mcp.bundle.cjs (single file, no n
137
172
 
138
173
  ```bash
139
174
  SOLARI_API_KEY=slr_live_… \
140
- SOLARI_BASE_URL=https://desktops-sta.solaribrowser.com \
141
- SOLARI_BROWSER_URL=https://api-sta.solaribrowser.com \
175
+ SOLARI_BASE_URL=https://api-sta.getsolari.com \
176
+ SOLARI_BROWSER_URL=https://api-sta.getsolari.com \
142
177
  node test/live-smoke.mjs # add SMOKE_SERVER=dist/solari-mcp.bundle.cjs to test the bundle
143
178
  ```
144
179
 
145
180
  Round-trips browser create→navigate→read→screenshot→search→close, sandbox
146
181
  create→exec→run_code→kill, and desktop create→screenshot→kill as a real MCP
147
182
  client over stdio.
183
+
184
+ ## Hosted connector — where it runs (ops)
185
+
186
+ All of this is **manual**, not terraform.
187
+
188
+ | | Prod | Staging |
189
+ |---|---|---|
190
+ | URL | `https://mcp.getsolari.com/mcp` | *(not deployed — see above)* |
191
+ | Region | **us-west-1** | — |
192
+ | Cluster | `solari-desktops-prod-usw1-gw` | — |
193
+ | Service / taskdef | `solari-mcp-prod-usw1` | — |
194
+ | Image | ECR `solari-mcp:prod` (us-west-1) | — |
195
+ | Ingress | listener rule **prio 20** on the gateway ALB, host-header match | — |
196
+
197
+ It rides the desktop gateway's ALB, so it is **coupled to that ALB's lifetime**.
198
+
199
+ ### ⚠️ `/health` returning 200 does NOT mean the connector is up
200
+
201
+ The desktop gateway answers `GET /health` with `{"ok":true}` too. If DNS points at the
202
+ gateway instead of the connector — which is exactly what happened on 2026-07-28, when the
203
+ us-west-1 migration deleted the old us-east-1 gateway ALB and re-aliased
204
+ `mcp.getsolari.com` to the new one — `/health` still returns 200 while **every MCP call
205
+ 404s**. The connector was down and the health check was green.
206
+
207
+ Two checks that actually discriminate:
208
+
209
+ ```sh
210
+ curl -s https://mcp.getsolari.com/health # connector's body has a "sessions" field; the gateway's does not
211
+ curl -s -o /dev/null -w '%{http_code}\n' -XPOST https://mcp.getsolari.com/mcp \
212
+ -H 'content-type: application/json' -d '{}'
213
+ # 401 = the connector (auth required) 404 = the gateway is answering — connector NOT reachable
214
+ ```
215
+
216
+ **If you move or rebuild the desktop gateway ALB, you must re-point this service too** —
217
+ target group, listener rule, and the Route53 alias all live on that ALB.
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,37 +94,106 @@ 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. " +
65
- "Returns a sessionId for the other solari_browser_* tools. Options: stealth (anti-bot " +
66
- "hardened pool), proxy ('smart' | country code like 'us' requires stealth), captcha " +
67
- "auto-solving (requires stealth), recording (rrweb replay).",
134
+ "Returns a sessionId for the other solari_browser_* tools.\n" +
135
+ "mode defaults to 'stealth' the anti-bot hardened pool. Just call this with no " +
136
+ "arguments and you get it; you do NOT need to ask for stealth explicitly. Stealth is " +
137
+ "the right default for the open web: ordinary sites work fine on it, and it is what " +
138
+ "keeps bot-detection from blocking the session.\n" +
139
+ "Pass mode:'fast' ONLY for a site you already know does not fingerprint or block " +
140
+ "automation (internal tools, localhost, your own app, plain docs/API pages) and you " +
141
+ "want the lower-latency pool. If a page in fast mode returns a block/captcha/challenge, " +
142
+ "close the session and retry with mode:'stealth'.\n" +
143
+ "captcha auto-solving is ALSO on by default (it follows the pool: on for stealth, off " +
144
+ "for fast). Pass captcha:false to turn it off — solving a challenge costs money and " +
145
+ "adds latency, so switch it off for sites you know never challenge.\n" +
146
+ "proxy ('smart' | country code like 'us') REQUIRES stealth, as does captcha; both are " +
147
+ "rejected if you ask for them explicitly in fast mode. recording gives an rrweb replay.",
68
148
  inputSchema: {
149
+ mode: z.enum(["stealth", "fast"]).optional(),
150
+ /** @deprecated use mode. Kept so existing callers keep working. */
69
151
  stealth: z.boolean().optional(),
70
152
  proxy: z.string().optional(),
153
+ /** Defaults to ON (following the pool). Pass false to opt out. */
71
154
  captcha: z.boolean().optional(),
72
155
  recording: z.boolean().optional(),
73
156
  profileId: z.string().optional(),
74
157
  },
75
158
  handler: async (a) => {
159
+ // STEALTH IS THE DEFAULT. Models consistently declined to opt in when it
160
+ // was an optional boolean, so sessions silently landed on the fast pool
161
+ // and got blocked by bot detection — the failure looked like "the site
162
+ // is broken" rather than "we picked the wrong pool". Defaulting on, with
163
+ // an explicit escape hatch, removes that whole class of confusion.
164
+ //
165
+ // Precedence: explicit mode wins; then the deprecated stealth boolean
166
+ // (so `stealth:false` still means fast for old callers); then stealth.
167
+ const stealth = a.mode !== undefined ? a.mode === "stealth" : (a.stealth ?? true);
168
+ // CAPTCHA IS ALSO ON BY DEFAULT — same reasoning as stealth: a model
169
+ // that has to opt in does not, and the resulting failure ("the page is
170
+ // a challenge screen") reads as a broken site rather than a missing
171
+ // option.
172
+ //
173
+ // The default FOLLOWS the pool rather than being a flat `true`, because
174
+ // captcha is stealth-only upstream. A flat true would make plain
175
+ // mode:'fast' throw on an option the caller never asked for, turning the
176
+ // escape hatch into a dead end. So: stealth ⇒ on, fast ⇒ off, and an
177
+ // explicit value always wins (captcha:false is the opt-out).
178
+ const captcha = a.captcha ?? stealth;
179
+ // proxy/captcha are stealth-only upstream. Previously a fast-pool
180
+ // session with proxy came back with the proxy silently dropped and only
181
+ // a note in the response; fail loudly instead — a silently unproxied
182
+ // request can leak the origin IP, which is the one thing the caller was
183
+ // trying to avoid. Only an EXPLICIT ask conflicts; the captcha default
184
+ // simply turns itself off in fast mode.
185
+ if (!stealth) {
186
+ const needsStealth = [a.proxy ? "proxy" : "", a.captcha === true ? "captcha" : ""].filter(Boolean);
187
+ if (needsStealth.length) {
188
+ throw new Error(`${needsStealth.join(" and ")} ${needsStealth.length > 1 ? "require" : "requires"} ` +
189
+ `stealth, but mode is 'fast'. ` +
190
+ `Drop ${needsStealth.length > 1 ? "them" : "it"} or use mode:'stealth'.`);
191
+ }
192
+ }
76
193
  const body = {};
77
- if (a.stealth)
194
+ if (stealth)
78
195
  body.stealth = true;
79
- if (a.captcha)
196
+ if (captcha)
80
197
  body.captcha = true;
81
198
  if (a.recording)
82
199
  body.recording = true;
@@ -88,21 +205,55 @@ export function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
88
205
  if (res.status !== 201)
89
206
  await apiError(res, "session create");
90
207
  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
- });
208
+ // cdpEndpoint is optional on the wire; derive it from wsEndpoint the
209
+ // way the official SDK does.
210
+ const cdp = s.cdpEndpoint ?? s.wsEndpoint?.replace("/ws/", "/cdp/");
211
+ if (!cdp)
212
+ throw new Error("gateway returned no cdpEndpoint or wsEndpoint");
213
+ // From here on the session is BILLABLE. Any failure before it is
214
+ // registered must release it, or it leaks until TTL with an id the
215
+ // model never saw.
216
+ try {
217
+ let browser;
218
+ let lastErr;
219
+ for (let i = 0; i < CDP_DIAL_ATTEMPTS; i++) {
220
+ try {
221
+ browser = await deps.connect(cdp);
222
+ break;
223
+ }
224
+ catch (err) {
225
+ // A freshly-started session has a transient upstream window.
226
+ lastErr = err;
227
+ await new Promise((r) => setTimeout(r, 500 * (i + 1)));
228
+ }
229
+ }
230
+ if (!browser)
231
+ throw lastErr ?? new Error("could not connect to the browser");
232
+ const pages = await browser.pages();
233
+ const page = pages.find((p) => !p.isClosed()) ?? (await browser.newPage());
234
+ reg.sessions.set(s.sessionId, {
235
+ sessionId: s.sessionId,
236
+ browser,
237
+ page,
238
+ expiresAt: s.expiresAt,
239
+ recording: Boolean(a.recording),
240
+ });
241
+ }
242
+ catch (err) {
243
+ await releaseBrowserSession(cfg, s.sessionId, api).catch(() => { });
244
+ throw new Error(`browser session started but could not be attached (released it): ${redact(err instanceof Error ? err.message : String(err))}`);
245
+ }
101
246
  // Proxy silently degrades rather than erroring — surface what we got.
247
+ // Echo the resolved mode back: the caller did not necessarily pick it
248
+ // (stealth is the default), and knowing which pool it landed on is what
249
+ // makes a later block actionable — "retry on stealth" vs "this site
250
+ // blocks us even hardened".
102
251
  return text({
103
252
  sessionId: s.sessionId,
253
+ mode: stealth ? "stealth" : "fast",
254
+ captcha,
104
255
  expiresAt: s.expiresAt,
105
- proxy: s.proxy ?? (a.proxy ? "NOT APPLIED (check plan/stealth)" : undefined),
256
+ proxy: s.proxy ?? (a.proxy ? "NOT APPLIED (check plan)" : undefined),
106
257
  });
107
258
  },
108
259
  },
@@ -111,13 +262,27 @@ export function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
111
262
  inputSchema: { sessionId: z.string(), url: z.string() },
112
263
  handler: async (a) => {
113
264
  const page = await activePage(need(a.sessionId));
114
- const resp = await page.goto(a.url, { waitUntil: "domcontentloaded", timeout: 60_000 });
265
+ let url = a.url.trim();
266
+ if (!/^[a-z][a-z0-9+.-]*:/i.test(url))
267
+ url = `https://${url}`;
268
+ const scheme = url.slice(0, url.indexOf(":")).toLowerCase();
269
+ if (scheme !== "http" && scheme !== "https") {
270
+ throw new Error(`refusing to navigate to a non-http(s) URL (${scheme}:)`);
271
+ }
272
+ // Block link-local / cloud metadata so page content can't steer the
273
+ // agent into reading the browser pool's instance credentials.
274
+ const host = new URL(url).hostname;
275
+ if (/^(169\.254\.|127\.|\[?::1\]?$|localhost$|metadata\.google)/i.test(host)) {
276
+ throw new Error(`refusing to navigate to internal host ${host}`);
277
+ }
278
+ const resp = await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 });
115
279
  return text({ url: page.url(), title: await page.title(), status: resp?.status() ?? null });
116
280
  },
117
281
  },
118
282
  solari_browser_read_page: {
119
283
  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.",
284
+ "clickable links {text, href}; 'html' returns HTML with scripts/styles stripped. " +
285
+ "Large output is truncated with an explicit marker.",
121
286
  inputSchema: {
122
287
  sessionId: z.string(),
123
288
  format: z.enum(["text", "links", "html"]).optional(),
@@ -125,35 +290,45 @@ export function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
125
290
  handler: async (a) => {
126
291
  const page = await activePage(need(a.sessionId));
127
292
  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
293
+ if (fmt === "links") {
294
+ const all = await page.$$eval("a[href]", (as) => as
134
295
  .map((el) => ({
135
296
  text: (el.textContent ?? "").trim().slice(0, 120),
136
297
  href: el.href,
137
298
  }))
138
- .filter((l) => l.text)
139
- .slice(0, 200));
140
- out = JSON.stringify(links, null, 2);
299
+ .filter((l) => l.text));
300
+ // Truncate whole elements — slicing the serialized JSON would hand
301
+ // the model unparseable output.
302
+ const shown = all.slice(0, MAX_LINKS);
303
+ return text({ url: page.url(), shown: shown.length, total: all.length, links: shown });
304
+ }
305
+ let out;
306
+ if (fmt === "html") {
307
+ out = await page.evaluate(() => {
308
+ const d = document.cloneNode(true);
309
+ d.querySelectorAll("script,style,noscript,svg").forEach((n) => n.remove());
310
+ return d.documentElement?.outerHTML ?? "";
311
+ });
141
312
  }
142
313
  else {
143
314
  out = await page.evaluate(() => document.body?.innerText ?? "");
144
315
  }
145
- const truncated = out.length > MAX_PAGE_TEXT;
146
- return text(`${page.url()}\n\n${out.slice(0, MAX_PAGE_TEXT)}${truncated ? "\n…[truncated]" : ""}`);
316
+ return text(`${page.url()}\n\n${capText(out, "chars")}`);
147
317
  },
148
318
  },
149
319
  solari_browser_screenshot: {
150
- description: "Screenshot the browser session's current page (JPEG image).",
151
- inputSchema: { sessionId: z.string(), fullPage: z.boolean().optional() },
320
+ description: "Screenshot the browser session's current page (JPEG). fullPage captures the whole " +
321
+ "scrollable page (may be downscaled by the client if very tall).",
322
+ inputSchema: {
323
+ sessionId: z.string(),
324
+ fullPage: z.boolean().optional(),
325
+ quality: z.number().min(1).max(100).optional(),
326
+ },
152
327
  handler: async (a) => {
153
328
  const page = await activePage(need(a.sessionId));
154
329
  const buf = await page.screenshot({
155
330
  type: "jpeg",
156
- quality: 75,
331
+ quality: a.quality ?? 75,
157
332
  fullPage: Boolean(a.fullPage),
158
333
  });
159
334
  return {
@@ -186,17 +361,28 @@ export function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
186
361
  },
187
362
  },
188
363
  solari_browser_type: {
189
- description: "Type text into the page. Optionally focus a CSS selector first. Set pressEnter to submit.",
364
+ description: "Type text into the page. Optionally focus a CSS selector first. clear:true replaces the " +
365
+ "field's existing value (otherwise text is appended at the caret). pressEnter submits.",
190
366
  inputSchema: {
191
367
  sessionId: z.string(),
192
368
  text: z.string(),
193
369
  selector: z.string().optional(),
370
+ clear: z.boolean().optional(),
194
371
  pressEnter: z.boolean().optional(),
195
372
  },
196
373
  handler: async (a) => {
197
374
  const page = await activePage(need(a.sessionId));
198
- if (a.selector)
199
- await page.click(a.selector);
375
+ if (a.selector) {
376
+ const sel = a.selector;
377
+ await page.focus(sel);
378
+ if (a.clear) {
379
+ await page.$eval(sel, (el) => {
380
+ const f = el;
381
+ f.value = "";
382
+ f.dispatchEvent(new Event("input", { bubbles: true }));
383
+ });
384
+ }
385
+ }
200
386
  await page.keyboard.type(a.text, { delay: 20 });
201
387
  if (a.pressEnter)
202
388
  await page.keyboard.press("Enter");
@@ -204,12 +390,22 @@ export function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
204
390
  },
205
391
  },
206
392
  solari_browser_key: {
207
- description: "Press a key in the browser session (e.g. 'Enter', 'Escape', 'ArrowDown', 'PageDown').",
393
+ description: "Press a key in the browser session (e.g. 'Enter', 'Escape', 'ArrowDown', 'PageDown'). " +
394
+ "Chords use '+' (e.g. 'Control+a').",
208
395
  inputSchema: { sessionId: z.string(), key: z.string() },
209
396
  handler: async (a) => {
210
397
  const page = await activePage(need(a.sessionId));
211
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
212
- await page.keyboard.press(a.key);
398
+ const parts = a.key.split("+").filter(Boolean);
399
+ const key = parts.pop();
400
+ for (const m of parts)
401
+ await page.keyboard.down(m);
402
+ try {
403
+ await page.keyboard.press(key);
404
+ }
405
+ finally {
406
+ for (const m of parts.reverse())
407
+ await page.keyboard.up(m);
408
+ }
213
409
  return text({ ok: true });
214
410
  },
215
411
  },
@@ -218,17 +414,11 @@ export function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
218
414
  inputSchema: { sessionId: z.string(), expression: z.string() },
219
415
  handler: async (a) => {
220
416
  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);
417
+ // Pass the expression as a string: puppeteer sends it as a
418
+ // debugger-originated Runtime.evaluate, which is exempt from the
419
+ // page's CSP. Wrapping it in eval() inside page context is not.
420
+ const result = await page.evaluate(a.expression);
421
+ return text(result === undefined ? "undefined" : result);
232
422
  },
233
423
  },
234
424
  solari_browser_replay_url: {
@@ -237,6 +427,10 @@ export function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
237
427
  inputSchema: { sessionId: z.string() },
238
428
  handler: async (a) => {
239
429
  const id = a.sessionId;
430
+ const known = reg.sessions.get(id);
431
+ if (known && !known.recording) {
432
+ throw new Error(`session ${id} was not created with recording: true, so it has no replay`);
433
+ }
240
434
  const res = await api(cfg, "GET", `/sessions/${encodeURIComponent(id)}/replay-url`);
241
435
  if (!res.ok)
242
436
  await apiError(res, "replay-url");
@@ -248,6 +442,9 @@ export function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
248
442
  inputSchema: { sessionId: z.string() },
249
443
  handler: async (a) => {
250
444
  const id = a.sessionId;
445
+ // Release FIRST: if it fails, keep the registry entry so the model can
446
+ // retry rather than losing the handle to a still-billing session.
447
+ await releaseBrowserSession(cfg, id, api);
251
448
  const e = reg.sessions.get(id);
252
449
  if (e) {
253
450
  try {
@@ -258,31 +455,28 @@ export function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
258
455
  }
259
456
  reg.sessions.delete(id);
260
457
  }
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
458
  return text({ ok: true });
285
459
  },
286
460
  },
287
461
  };
288
462
  }
463
+ /** Release every browser session in a registry (used on eviction/shutdown). */
464
+ export async function releaseAllBrowserSessions(cfg, reg, fetchApi = api) {
465
+ const ids = [...reg.sessions.keys()];
466
+ await Promise.all(ids.map(async (id) => {
467
+ const e = reg.sessions.get(id);
468
+ reg.sessions.delete(id);
469
+ try {
470
+ await releaseBrowserSession(cfg, id, fetchApi);
471
+ }
472
+ catch (err) {
473
+ console.error(`solari-mcp: failed to release browser session ${id}:`, err);
474
+ }
475
+ try {
476
+ await e?.browser.disconnect();
477
+ }
478
+ catch {
479
+ /* already gone */
480
+ }
481
+ }));
482
+ }