@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 +77 -7
- package/dist/browser.d.ts +4 -0
- package/dist/browser.js +276 -82
- package/dist/http.js +125 -58
- package/dist/server.d.ts +13 -0
- package/dist/server.js +108 -17
- package/dist/solari-mcp-http.bundle.cjs +114750 -0
- package/dist/solari-mcp.bundle.cjs +113234 -0
- package/package.json +7 -6
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:**
|
|
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
|
-
//
|
|
72
|
-
// "
|
|
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
|
|
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 (`
|
|
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://
|
|
141
|
-
SOLARI_BROWSER_URL=https://api-sta.
|
|
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
|
-
|
|
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,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
|
-
//
|
|
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. " +
|
|
65
|
-
"Returns a sessionId for the other solari_browser_* tools
|
|
66
|
-
"
|
|
67
|
-
"
|
|
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 (
|
|
194
|
+
if (stealth)
|
|
78
195
|
body.stealth = true;
|
|
79
|
-
if (
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
|
151
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
+
}
|