@solarisdk/mcp 0.3.1 → 0.3.3
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 +35 -1
- package/dist/browser.d.ts +9 -0
- package/dist/browser.js +250 -0
- package/dist/server.js +57 -4
- package/dist/solari-mcp-http.bundle.cjs +240 -5
- package/dist/solari-mcp.bundle.cjs +240 -5
- package/dist/version.d.ts +1 -0
- package/dist/version.js +11 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -103,6 +103,9 @@ Chat, Code and Cowork alike.
|
|
|
103
103
|
| `solari_browser_click` / `solari_browser_type` / `solari_browser_key` | Interact (selector or x/y) |
|
|
104
104
|
| `solari_browser_evaluate` | Run a JS expression on the page |
|
|
105
105
|
| `solari_browser_replay_url` | rrweb replay URL (needs `recording: true`) |
|
|
106
|
+
| `solari_browser_login` | Ask a HUMAN to sign in — returns a short-lived URL to show them |
|
|
107
|
+
| `solari_browser_await_login` | Wait for them to finish, then re-attach the session |
|
|
108
|
+
| `solari_browser_save_profile` | Persist the signed-in state to a profile (explicit — see below) |
|
|
106
109
|
| `solari_browser_close` | Release the session |
|
|
107
110
|
|
|
108
111
|
### Browser mode — stealth by default
|
|
@@ -138,13 +141,44 @@ asked for. Stealth ⇒ on, fast ⇒ off, explicit value always wins. Asking for
|
|
|
138
141
|
*together with* `mode: "fast"` is a real contradiction and is refused. The response echoes the
|
|
139
142
|
resolved `captcha` alongside `mode`.
|
|
140
143
|
|
|
144
|
+
### Login handoff — the agent never touches a password
|
|
145
|
+
|
|
146
|
+
When a session hits a login form or 2FA prompt, the agent calls
|
|
147
|
+
`solari_browser_login` and shows the returned URL to the user. They open it, get
|
|
148
|
+
a **live view of the exact page the agent is on**, and type their credentials
|
|
149
|
+
directly into it. The agent then calls `solari_browser_await_login` and carries
|
|
150
|
+
on where it left off — same page, now signed in.
|
|
151
|
+
|
|
152
|
+
**The agent is locked out for the duration.** The gateway refuses its CDP
|
|
153
|
+
upgrades *and* severs the socket it already holds, so it cannot watch the typing
|
|
154
|
+
even though it is mid-session. That is enforced at the proxy, not here: a check
|
|
155
|
+
in this layer would be advisory, because the agent speaks raw CDP.
|
|
156
|
+
|
|
157
|
+
Two consequences worth knowing:
|
|
158
|
+
|
|
159
|
+
- Other browser tools on that session fail while a handoff is open. That is the
|
|
160
|
+
freeze working, not a bug.
|
|
161
|
+
- `await_login` **reconnects** on success. The sever kills the stored puppeteer
|
|
162
|
+
handle, so resuming needs a fresh dial; without it the agent would "resume"
|
|
163
|
+
into a Target-closed error.
|
|
164
|
+
|
|
165
|
+
The link lives 5 minutes and is single-use. If it lapses, call
|
|
166
|
+
`solari_browser_login` again — the session is untouched.
|
|
167
|
+
|
|
168
|
+
**Saving the login for next time is explicit.** `solari_browser_save_profile`
|
|
169
|
+
captures the session's cookies + localStorage into a profile, and future runs
|
|
170
|
+
skip the human entirely via `create({profileId})`. It is deliberately *not*
|
|
171
|
+
automatic: those are live session cookies, which bypass 2FA, so the profile is a
|
|
172
|
+
credential store and should exist because the user chose it. The tool reports the
|
|
173
|
+
cookie count and origins it stored, so they can see what was persisted.
|
|
174
|
+
|
|
141
175
|
### Sandboxes + desktops
|
|
142
176
|
|
|
143
177
|
| Tool | What it does |
|
|
144
178
|
|---|---|
|
|
145
179
|
| `solari_sandbox_create` | Create a headless sandbox → `sessionId` |
|
|
146
180
|
| `solari_desktop_create` | Create a GUI desktop → `sessionId` + `streamUrl` |
|
|
147
|
-
| `solari_list` | List the org's sandboxes |
|
|
181
|
+
| `solari_list` | List the org's VMs — **both** sandboxes and desktops, each labelled with its `kind` (optional `kind`/`state` filters) |
|
|
148
182
|
| `solari_kill` | Destroy a session |
|
|
149
183
|
| `solari_connect` | Re-attach to a session by id across restarts (auto-resumes if paused) |
|
|
150
184
|
| `solari_exec` | Run a shell command (via `sh -c`) → `{stdout,stderr,exitCode}` |
|
package/dist/browser.d.ts
CHANGED
|
@@ -26,6 +26,15 @@ interface BrowserEntry {
|
|
|
26
26
|
page: Page;
|
|
27
27
|
expiresAt: string;
|
|
28
28
|
recording: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* The session's raw CDP endpoint, kept so the handoff flow can RECONNECT.
|
|
31
|
+
*
|
|
32
|
+
* A login handoff severs the agent's socket gateway-side — deliberately, so
|
|
33
|
+
* the agent cannot watch the human type. That kills this entry's puppeteer
|
|
34
|
+
* handle, so resuming afterwards needs a fresh connect rather than the dead
|
|
35
|
+
* one. Without this the agent "resumes" into a Target-closed error.
|
|
36
|
+
*/
|
|
37
|
+
cdpEndpoint: string;
|
|
29
38
|
}
|
|
30
39
|
export interface BrowserRegistry {
|
|
31
40
|
sessions: Map<string, BrowserEntry>;
|
package/dist/browser.js
CHANGED
|
@@ -12,6 +12,8 @@ const text = (o) => ({
|
|
|
12
12
|
const MAX_PAGE_TEXT = 30_000;
|
|
13
13
|
const MAX_LINKS = 200;
|
|
14
14
|
const CDP_DIAL_ATTEMPTS = 3;
|
|
15
|
+
/** How often await_login re-checks handoff status. */
|
|
16
|
+
const HANDOFF_POLL_MS = 2_000;
|
|
15
17
|
/** Signed ws/wss capability URLs must never reach the model or a log. */
|
|
16
18
|
const redact = (s) => s.replace(/wss?:\/\/[^\s"']+/gi, "[redacted-ws-url]");
|
|
17
19
|
async function api(cfg, method, path, body) {
|
|
@@ -237,6 +239,7 @@ export function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
|
|
|
237
239
|
page,
|
|
238
240
|
expiresAt: s.expiresAt,
|
|
239
241
|
recording: Boolean(a.recording),
|
|
242
|
+
cdpEndpoint: cdp,
|
|
240
243
|
});
|
|
241
244
|
}
|
|
242
245
|
catch (err) {
|
|
@@ -257,6 +260,253 @@ export function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
|
|
|
257
260
|
});
|
|
258
261
|
},
|
|
259
262
|
},
|
|
263
|
+
solari_browser_profiles: {
|
|
264
|
+
description: "List saved browser profiles for this account. A profile is a stored signed-in state " +
|
|
265
|
+
"(cookies + localStorage) that solari_browser_create({profileId}) replays, so the " +
|
|
266
|
+
"session starts already logged in.\n" +
|
|
267
|
+
"Check here FIRST when a task will need a login: if a profile for that site already " +
|
|
268
|
+
"exists, use it and no human is involved at all. `version` is 1 and `populated` is " +
|
|
269
|
+
"false for a profile nobody has signed into yet — that one still needs " +
|
|
270
|
+
"solari_browser_login({profileName}).",
|
|
271
|
+
inputSchema: {},
|
|
272
|
+
handler: async () => {
|
|
273
|
+
const res = await api(cfg, "GET", "/profiles");
|
|
274
|
+
if (res.status !== 200)
|
|
275
|
+
await apiError(res, "list profiles");
|
|
276
|
+
const rows = (await res.json());
|
|
277
|
+
return text({
|
|
278
|
+
profiles: rows.map((p) => ({
|
|
279
|
+
profileId: p.id,
|
|
280
|
+
name: p.name,
|
|
281
|
+
version: p.version,
|
|
282
|
+
populated: Boolean(p.storageStateS3Key),
|
|
283
|
+
lastUsedAt: p.lastUsedAt ?? null,
|
|
284
|
+
})),
|
|
285
|
+
});
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
solari_browser_login: {
|
|
289
|
+
description: "Ask a HUMAN to sign in, so you never handle credentials yourself. Two ways to call it:\n" +
|
|
290
|
+
"HOT — pass `sessionId` when you are ALREADY on a login wall mid-task. The user gets a " +
|
|
291
|
+
"live view of the exact page you are on and types into it; you resume on that same page.\n" +
|
|
292
|
+
"COLD — pass `profileName` when you know a task will need a login and no session is open " +
|
|
293
|
+
"yet. The user signs in once in a profile editor, and every later " +
|
|
294
|
+
"solari_browser_create({profileId}) starts already authenticated. Prefer this when you " +
|
|
295
|
+
"can: nobody has to be watching mid-run. The profile is created if it does not exist.\n" +
|
|
296
|
+
"Pass exactly one of the two. Either way, SHOW THE RETURNED URL TO THE USER, then call " +
|
|
297
|
+
"solari_browser_await_login.\n" +
|
|
298
|
+
"In the HOT case your access to that session is REVOKED while the handoff is open — " +
|
|
299
|
+
"deliberately, so you cannot observe what they type — and the link expires in 5 minutes. " +
|
|
300
|
+
"Cold links last longer and revoke nothing, because there is no session yet.\n" +
|
|
301
|
+
"`reason` is required and is shown to the user — say plainly which site is asking and " +
|
|
302
|
+
"what for, because they are being asked to type a password on your say-so.",
|
|
303
|
+
inputSchema: {
|
|
304
|
+
sessionId: z.string().optional(),
|
|
305
|
+
profileName: z.string().optional(),
|
|
306
|
+
reason: z.string(),
|
|
307
|
+
},
|
|
308
|
+
handler: async (a) => {
|
|
309
|
+
const sessionId = typeof a.sessionId === "string" ? a.sessionId : "";
|
|
310
|
+
const profileName = typeof a.profileName === "string" ? a.profileName.trim() : "";
|
|
311
|
+
if (Boolean(sessionId) === Boolean(profileName)) {
|
|
312
|
+
throw new Error("Pass exactly one of sessionId (rescue a live session) or profileName (seed a " +
|
|
313
|
+
"profile before you start).");
|
|
314
|
+
}
|
|
315
|
+
// ── COLD: seed a profile, no session involved ────────────────────
|
|
316
|
+
if (profileName) {
|
|
317
|
+
const listRes = await api(cfg, "GET", "/profiles");
|
|
318
|
+
if (listRes.status !== 200)
|
|
319
|
+
await apiError(listRes, "list profiles");
|
|
320
|
+
const rows = (await listRes.json());
|
|
321
|
+
const match = rows.find((p) => (p.name ?? "").toLowerCase() === profileName.toLowerCase());
|
|
322
|
+
let profileId = match?.id ?? "";
|
|
323
|
+
if (!profileId) {
|
|
324
|
+
const mk = await api(cfg, "POST", "/profiles", { name: profileName });
|
|
325
|
+
if (mk.status !== 200 && mk.status !== 201)
|
|
326
|
+
await apiError(mk, "create profile");
|
|
327
|
+
profileId = (await mk.json()).id ?? "";
|
|
328
|
+
if (!profileId)
|
|
329
|
+
throw new Error("profile created but no id returned");
|
|
330
|
+
}
|
|
331
|
+
const hRes = await api(cfg, "POST", `/profiles/${encodeURIComponent(profileId)}/login-handoff`, {
|
|
332
|
+
reason: a.reason,
|
|
333
|
+
});
|
|
334
|
+
if (hRes.status !== 200)
|
|
335
|
+
await apiError(hRes, "cold login request");
|
|
336
|
+
const h = (await hRes.json());
|
|
337
|
+
return text({
|
|
338
|
+
mode: "cold",
|
|
339
|
+
profileId,
|
|
340
|
+
profileName,
|
|
341
|
+
handoffId: h.handoffId,
|
|
342
|
+
url: h.url,
|
|
343
|
+
expiresAt: h.expiresAt,
|
|
344
|
+
// Echoed so await_login can tell a fresh save from the state it
|
|
345
|
+
// started in — the version bump IS the completion signal.
|
|
346
|
+
sinceVersion: h.version,
|
|
347
|
+
next: `Show the url to the user, then call solari_browser_await_login({ profileId: ` +
|
|
348
|
+
`"${profileId}", sinceVersion: ${h.version} }).`,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
// ── HOT: rescue the live session ─────────────────────────────────
|
|
352
|
+
const e = need(sessionId);
|
|
353
|
+
const res = await api(cfg, "POST", `/sessions/${encodeURIComponent(sessionId)}/handoff`, { reason: a.reason });
|
|
354
|
+
if (res.status !== 200)
|
|
355
|
+
await apiError(res, "handoff request");
|
|
356
|
+
const h = (await res.json());
|
|
357
|
+
// The gateway is severing our socket right now. Drop the local handle
|
|
358
|
+
// rather than leave a half-dead Browser object that throws confusing
|
|
359
|
+
// Target-closed errors on every subsequent tool call.
|
|
360
|
+
try {
|
|
361
|
+
await e.browser.close();
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
/* already gone — that is the point */
|
|
365
|
+
}
|
|
366
|
+
return text({
|
|
367
|
+
mode: "hot",
|
|
368
|
+
handoffId: h.handoffId,
|
|
369
|
+
url: h.url,
|
|
370
|
+
expiresAt: h.expiresAt,
|
|
371
|
+
next: "Show the url to the user, then call solari_browser_await_login.",
|
|
372
|
+
});
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
solari_browser_save_profile: {
|
|
376
|
+
description: "Save this session's signed-in state (cookies + localStorage) into an existing profile, " +
|
|
377
|
+
"so future sessions start already logged in and no human is needed again. Pass the " +
|
|
378
|
+
"profileId to overwrite.\n" +
|
|
379
|
+
"ONLY do this when the user has asked you to remember the login. It is deliberately not " +
|
|
380
|
+
"automatic: a saved profile holds live session cookies, which bypass 2FA — it is a " +
|
|
381
|
+
"credential store, and it should exist because someone chose it. If they have not said " +
|
|
382
|
+
"so, ask first.\n" +
|
|
383
|
+
"Returns what was captured (cookie count and origins) so the user can see what they " +
|
|
384
|
+
"just persisted.",
|
|
385
|
+
inputSchema: {
|
|
386
|
+
sessionId: z.string(),
|
|
387
|
+
profileId: z.string(),
|
|
388
|
+
},
|
|
389
|
+
handler: async (a) => {
|
|
390
|
+
need(a.sessionId);
|
|
391
|
+
const res = await api(cfg, "POST", `/sessions/${encodeURIComponent(a.sessionId)}/save-profile`, { profileId: a.profileId });
|
|
392
|
+
if (res.status !== 200)
|
|
393
|
+
await apiError(res, "save profile");
|
|
394
|
+
const body = (await res.json());
|
|
395
|
+
return text({
|
|
396
|
+
...body,
|
|
397
|
+
note: "Future sessions can use this with solari_browser_create({profileId}).",
|
|
398
|
+
});
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
solari_browser_await_login: {
|
|
402
|
+
description: "Wait for the human to finish the sign-in you requested with solari_browser_login. " +
|
|
403
|
+
"Blocks until they are done, the link expires, or timeoutMs elapses. Returns only a " +
|
|
404
|
+
"status — never anything the user typed. On 'completed' the session is yours again, " +
|
|
405
|
+
"on the same page, still signed in, and you can carry on where you left off. On " +
|
|
406
|
+
"'expired' or 'timeout' the user did not finish: ask if they still want to, and call " +
|
|
407
|
+
"solari_browser_login again for a fresh link rather than retrying this.",
|
|
408
|
+
inputSchema: {
|
|
409
|
+
sessionId: z.string().optional(),
|
|
410
|
+
profileId: z.string().optional(),
|
|
411
|
+
sinceVersion: z.number().optional(),
|
|
412
|
+
handoffId: z.string().optional(),
|
|
413
|
+
timeoutMs: z.number().optional(),
|
|
414
|
+
},
|
|
415
|
+
handler: async (a) => {
|
|
416
|
+
const sessionId = typeof a.sessionId === "string" ? a.sessionId : "";
|
|
417
|
+
const profileId = typeof a.profileId === "string" ? a.profileId : "";
|
|
418
|
+
if (Boolean(sessionId) === Boolean(profileId)) {
|
|
419
|
+
throw new Error("Pass exactly one of sessionId (hot handoff) or profileId (cold handoff) — the " +
|
|
420
|
+
"same one solari_browser_login returned.");
|
|
421
|
+
}
|
|
422
|
+
// Clamp: never spin forever, never poll so briefly the human has no
|
|
423
|
+
// chance. Defaults to the handoff's own 5-minute life.
|
|
424
|
+
const requested = typeof a.timeoutMs === "number" ? a.timeoutMs : 300_000;
|
|
425
|
+
const deadline = Date.now() + Math.min(Math.max(requested, 5_000), 600_000);
|
|
426
|
+
let status = "pending";
|
|
427
|
+
// ── COLD: watch the profile's version, which the editor's save bumps.
|
|
428
|
+
// Deliberately NOT "did a storageState appear": re-signing into a
|
|
429
|
+
// profile that already had one must also count as completed.
|
|
430
|
+
if (profileId) {
|
|
431
|
+
const since = typeof a.sinceVersion === "number" ? a.sinceVersion : 0;
|
|
432
|
+
let version = since;
|
|
433
|
+
while (Date.now() < deadline) {
|
|
434
|
+
const res = await api(cfg, "GET", "/profiles");
|
|
435
|
+
if (res.status !== 200)
|
|
436
|
+
await apiError(res, "profile status");
|
|
437
|
+
const rows = (await res.json());
|
|
438
|
+
const row = rows.find((p) => p.id === profileId);
|
|
439
|
+
if (!row)
|
|
440
|
+
throw new Error(`profile ${profileId} no longer exists`);
|
|
441
|
+
version = typeof row.version === "number" ? row.version : since;
|
|
442
|
+
if (version > since) {
|
|
443
|
+
status = "completed";
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
await new Promise((r) => setTimeout(r, HANDOFF_POLL_MS));
|
|
447
|
+
}
|
|
448
|
+
if (status !== "completed")
|
|
449
|
+
status = "timeout";
|
|
450
|
+
return text({
|
|
451
|
+
mode: "cold",
|
|
452
|
+
status,
|
|
453
|
+
profileId,
|
|
454
|
+
version,
|
|
455
|
+
next: status === "completed"
|
|
456
|
+
? `Signed in and saved. Use solari_browser_create({ profileId: "${profileId}" }) ` +
|
|
457
|
+
"and you will start already authenticated."
|
|
458
|
+
: "The user did not finish. Ask if they still want to, then call " +
|
|
459
|
+
"solari_browser_login again for a fresh link.",
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
const e = need(sessionId);
|
|
463
|
+
while (Date.now() < deadline) {
|
|
464
|
+
const q = typeof a.handoffId === "string" && a.handoffId
|
|
465
|
+
? `?handoffId=${encodeURIComponent(a.handoffId)}`
|
|
466
|
+
: "";
|
|
467
|
+
const res = await api(cfg, "GET", `/sessions/${encodeURIComponent(sessionId)}/handoff${q}`);
|
|
468
|
+
if (res.status !== 200)
|
|
469
|
+
await apiError(res, "handoff status");
|
|
470
|
+
const body = (await res.json());
|
|
471
|
+
status = body.status ?? "none";
|
|
472
|
+
// "none" means no open handoff and no id to look up — treat as done
|
|
473
|
+
// rather than spinning; the caller can re-mint if that was wrong.
|
|
474
|
+
if (status !== "pending")
|
|
475
|
+
break;
|
|
476
|
+
await new Promise((r) => setTimeout(r, HANDOFF_POLL_MS));
|
|
477
|
+
}
|
|
478
|
+
if (status === "pending")
|
|
479
|
+
status = "timeout";
|
|
480
|
+
// Reconnect: the handoff severed our old socket on purpose, so the
|
|
481
|
+
// stored handle is dead even on success. Re-dial and re-adopt the page
|
|
482
|
+
// the human left us on, which is the whole point of a HOT handoff —
|
|
483
|
+
// the agent resumes mid-flow instead of starting over.
|
|
484
|
+
let resumed = false;
|
|
485
|
+
if (status === "completed" || status === "none") {
|
|
486
|
+
try {
|
|
487
|
+
const browser = await deps.connect(e.cdpEndpoint);
|
|
488
|
+
const pages = await browser.pages();
|
|
489
|
+
const page = pages.find((p) => !p.isClosed()) ?? (await browser.newPage());
|
|
490
|
+
e.browser = browser;
|
|
491
|
+
e.page = page;
|
|
492
|
+
resumed = true;
|
|
493
|
+
}
|
|
494
|
+
catch (err) {
|
|
495
|
+
return text({
|
|
496
|
+
status,
|
|
497
|
+
resumed: false,
|
|
498
|
+
note: "the sign-in finished but the session could not be re-attached: " +
|
|
499
|
+
redact(err instanceof Error ? err.message : String(err)),
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return text({
|
|
504
|
+
status,
|
|
505
|
+
resumed,
|
|
506
|
+
...(resumed ? { url: e.page.url() } : {}),
|
|
507
|
+
});
|
|
508
|
+
},
|
|
509
|
+
},
|
|
260
510
|
solari_browser_navigate: {
|
|
261
511
|
description: "Navigate the browser session to a URL. Returns final url, title and HTTP status.",
|
|
262
512
|
inputSchema: { sessionId: z.string(), url: z.string() },
|
package/dist/server.js
CHANGED
|
@@ -12,11 +12,14 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
12
12
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
13
13
|
import { z } from "zod";
|
|
14
14
|
import { SolariClient } from "@solarisdk/sdk";
|
|
15
|
+
import { VERSION } from "./version.js";
|
|
15
16
|
import { makeBrowserToolset, releaseAllBrowserSessions, } from "./browser.js";
|
|
16
17
|
// Guest output (file reads, command stdout, code results) is unbounded on the
|
|
17
18
|
// wire; cap it so one `cat` of a big file can't blow the MCP payload budget or
|
|
18
19
|
// OOM the shared hosted task.
|
|
19
20
|
const MAX_TOOL_TEXT = 30_000;
|
|
21
|
+
// Pages of `GET /sandboxes` solari_list will follow per kind (100 rows each).
|
|
22
|
+
const MAX_LIST_PAGES = 5;
|
|
20
23
|
const text = (o) => {
|
|
21
24
|
const s = typeof o === "string" ? o : JSON.stringify(o, null, 2);
|
|
22
25
|
const capped = s.length > MAX_TOOL_TEXT
|
|
@@ -81,9 +84,59 @@ export function makeToolset(client, reg) {
|
|
|
81
84
|
},
|
|
82
85
|
},
|
|
83
86
|
solari_list: {
|
|
84
|
-
description: "List the org's sandboxes."
|
|
85
|
-
|
|
86
|
-
|
|
87
|
+
description: "List the org's live VMs — BOTH sandboxes and desktops. Each entry is labelled " +
|
|
88
|
+
"with its `kind`; `registered:true` means this MCP session already holds a handle " +
|
|
89
|
+
"so the other tools accept its sessionId directly (otherwise call solari_connect " +
|
|
90
|
+
"first). Optional kind/state filters.",
|
|
91
|
+
inputSchema: {
|
|
92
|
+
kind: z.enum(["sandbox", "desktop"]).optional(),
|
|
93
|
+
state: z.string().optional(),
|
|
94
|
+
},
|
|
95
|
+
handler: async (a) => {
|
|
96
|
+
// GET /sandboxes is the unified VM index (desktops write the same
|
|
97
|
+
// SandboxRecord), but it is queried per-kind here so the two flavours
|
|
98
|
+
// are merged EXPLICITLY: an agent asking "what do I have running"
|
|
99
|
+
// must never silently lose desktops to a default filter, and the
|
|
100
|
+
// per-kind counts are what make the answer legible.
|
|
101
|
+
const kinds = a.kind ? [a.kind] : ["sandbox", "desktop"];
|
|
102
|
+
const vms = [];
|
|
103
|
+
const counts = { sandbox: 0, desktop: 0 };
|
|
104
|
+
let truncated = false;
|
|
105
|
+
for (const kind of kinds) {
|
|
106
|
+
// Follow nextCursor so a big org's desktops aren't cut off by the
|
|
107
|
+
// gateway's 100-row default page — bounded so one call can't spin.
|
|
108
|
+
let cursor;
|
|
109
|
+
for (let page = 0; page < MAX_LIST_PAGES; page++) {
|
|
110
|
+
const res = await client.sandboxes.list({
|
|
111
|
+
kind,
|
|
112
|
+
// `state` is a closed union on the SDK; an unknown value is
|
|
113
|
+
// simply ignored by the gateway, so pass it through.
|
|
114
|
+
...(a.state ? { state: a.state } : {}),
|
|
115
|
+
...(cursor ? { cursor } : {}),
|
|
116
|
+
});
|
|
117
|
+
for (const v of res.sandboxes ?? []) {
|
|
118
|
+
const rec = v;
|
|
119
|
+
// The wire calls the id `sandboxId` for both kinds; re-label it
|
|
120
|
+
// `sessionId` because that is what every other tool here takes.
|
|
121
|
+
const id = (rec.sandboxId ?? rec.sessionId);
|
|
122
|
+
const k = rec.kind ?? kind;
|
|
123
|
+
vms.push({
|
|
124
|
+
...rec,
|
|
125
|
+
...(id ? { sessionId: id } : {}),
|
|
126
|
+
kind: k,
|
|
127
|
+
registered: id ? reg.sessions.has(id) : false,
|
|
128
|
+
});
|
|
129
|
+
counts[k] = (counts[k] ?? 0) + 1;
|
|
130
|
+
}
|
|
131
|
+
cursor = res.nextCursor;
|
|
132
|
+
if (!cursor)
|
|
133
|
+
break;
|
|
134
|
+
if (page === MAX_LIST_PAGES - 1)
|
|
135
|
+
truncated = true;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return text({ counts, total: vms.length, ...(truncated ? { truncated } : {}), vms });
|
|
139
|
+
},
|
|
87
140
|
},
|
|
88
141
|
solari_kill: {
|
|
89
142
|
description: "Destroy a session by id.",
|
|
@@ -284,7 +337,7 @@ export function buildServerParts(client, browserCfg) {
|
|
|
284
337
|
process.env.SOLARI_BASE_URL ??
|
|
285
338
|
"https://api.getsolari.com",
|
|
286
339
|
};
|
|
287
|
-
const server = new McpServer({ name: "solari-mcp", version:
|
|
340
|
+
const server = new McpServer({ name: "solari-mcp", version: VERSION });
|
|
288
341
|
const browserReg = { sessions: new Map() };
|
|
289
342
|
const vmReg = { sessions: new Map() };
|
|
290
343
|
registerToolset(server, {
|
|
@@ -112357,6 +112357,9 @@ var SolariClient = class {
|
|
|
112357
112357
|
}
|
|
112358
112358
|
};
|
|
112359
112359
|
|
|
112360
|
+
// src/version.ts
|
|
112361
|
+
var VERSION = "0.3.3";
|
|
112362
|
+
|
|
112360
112363
|
// node_modules/puppeteer-core/lib/esm/puppeteer/index.js
|
|
112361
112364
|
init_index_browser();
|
|
112362
112365
|
|
|
@@ -113676,6 +113679,7 @@ var text = (o) => ({
|
|
|
113676
113679
|
var MAX_PAGE_TEXT = 3e4;
|
|
113677
113680
|
var MAX_LINKS = 200;
|
|
113678
113681
|
var CDP_DIAL_ATTEMPTS = 3;
|
|
113682
|
+
var HANDOFF_POLL_MS = 2e3;
|
|
113679
113683
|
var redact = (s) => s.replace(/wss?:\/\/[^\s"']+/gi, "[redacted-ws-url]");
|
|
113680
113684
|
async function api(cfg, method, path12, body) {
|
|
113681
113685
|
const res = await fetch(`${cfg.baseUrl}${path12}`, {
|
|
@@ -113821,7 +113825,8 @@ function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
|
|
|
113821
113825
|
browser,
|
|
113822
113826
|
page,
|
|
113823
113827
|
expiresAt: s.expiresAt,
|
|
113824
|
-
recording: Boolean(a2.recording)
|
|
113828
|
+
recording: Boolean(a2.recording),
|
|
113829
|
+
cdpEndpoint: cdp
|
|
113825
113830
|
});
|
|
113826
113831
|
} catch (err) {
|
|
113827
113832
|
await releaseBrowserSession(cfg, s.sessionId, api2).catch(() => {
|
|
@@ -113839,6 +113844,199 @@ function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
|
|
|
113839
113844
|
});
|
|
113840
113845
|
}
|
|
113841
113846
|
},
|
|
113847
|
+
solari_browser_profiles: {
|
|
113848
|
+
description: "List saved browser profiles for this account. A profile is a stored signed-in state (cookies + localStorage) that solari_browser_create({profileId}) replays, so the session starts already logged in.\nCheck here FIRST when a task will need a login: if a profile for that site already exists, use it and no human is involved at all. `version` is 1 and `populated` is false for a profile nobody has signed into yet \u2014 that one still needs solari_browser_login({profileName}).",
|
|
113849
|
+
inputSchema: {},
|
|
113850
|
+
handler: async () => {
|
|
113851
|
+
const res = await api2(cfg, "GET", "/profiles");
|
|
113852
|
+
if (res.status !== 200) await apiError(res, "list profiles");
|
|
113853
|
+
const rows = await res.json();
|
|
113854
|
+
return text({
|
|
113855
|
+
profiles: rows.map((p) => ({
|
|
113856
|
+
profileId: p.id,
|
|
113857
|
+
name: p.name,
|
|
113858
|
+
version: p.version,
|
|
113859
|
+
populated: Boolean(p.storageStateS3Key),
|
|
113860
|
+
lastUsedAt: p.lastUsedAt ?? null
|
|
113861
|
+
}))
|
|
113862
|
+
});
|
|
113863
|
+
}
|
|
113864
|
+
},
|
|
113865
|
+
solari_browser_login: {
|
|
113866
|
+
description: "Ask a HUMAN to sign in, so you never handle credentials yourself. Two ways to call it:\nHOT \u2014 pass `sessionId` when you are ALREADY on a login wall mid-task. The user gets a live view of the exact page you are on and types into it; you resume on that same page.\nCOLD \u2014 pass `profileName` when you know a task will need a login and no session is open yet. The user signs in once in a profile editor, and every later solari_browser_create({profileId}) starts already authenticated. Prefer this when you can: nobody has to be watching mid-run. The profile is created if it does not exist.\nPass exactly one of the two. Either way, SHOW THE RETURNED URL TO THE USER, then call solari_browser_await_login.\nIn the HOT case your access to that session is REVOKED while the handoff is open \u2014 deliberately, so you cannot observe what they type \u2014 and the link expires in 5 minutes. Cold links last longer and revoke nothing, because there is no session yet.\n`reason` is required and is shown to the user \u2014 say plainly which site is asking and what for, because they are being asked to type a password on your say-so.",
|
|
113867
|
+
inputSchema: {
|
|
113868
|
+
sessionId: external_exports.string().optional(),
|
|
113869
|
+
profileName: external_exports.string().optional(),
|
|
113870
|
+
reason: external_exports.string()
|
|
113871
|
+
},
|
|
113872
|
+
handler: async (a2) => {
|
|
113873
|
+
const sessionId = typeof a2.sessionId === "string" ? a2.sessionId : "";
|
|
113874
|
+
const profileName = typeof a2.profileName === "string" ? a2.profileName.trim() : "";
|
|
113875
|
+
if (Boolean(sessionId) === Boolean(profileName)) {
|
|
113876
|
+
throw new Error(
|
|
113877
|
+
"Pass exactly one of sessionId (rescue a live session) or profileName (seed a profile before you start)."
|
|
113878
|
+
);
|
|
113879
|
+
}
|
|
113880
|
+
if (profileName) {
|
|
113881
|
+
const listRes = await api2(cfg, "GET", "/profiles");
|
|
113882
|
+
if (listRes.status !== 200) await apiError(listRes, "list profiles");
|
|
113883
|
+
const rows = await listRes.json();
|
|
113884
|
+
const match = rows.find(
|
|
113885
|
+
(p) => (p.name ?? "").toLowerCase() === profileName.toLowerCase()
|
|
113886
|
+
);
|
|
113887
|
+
let profileId = match?.id ?? "";
|
|
113888
|
+
if (!profileId) {
|
|
113889
|
+
const mk = await api2(cfg, "POST", "/profiles", { name: profileName });
|
|
113890
|
+
if (mk.status !== 200 && mk.status !== 201) await apiError(mk, "create profile");
|
|
113891
|
+
profileId = (await mk.json()).id ?? "";
|
|
113892
|
+
if (!profileId) throw new Error("profile created but no id returned");
|
|
113893
|
+
}
|
|
113894
|
+
const hRes = await api2(cfg, "POST", `/profiles/${encodeURIComponent(profileId)}/login-handoff`, {
|
|
113895
|
+
reason: a2.reason
|
|
113896
|
+
});
|
|
113897
|
+
if (hRes.status !== 200) await apiError(hRes, "cold login request");
|
|
113898
|
+
const h2 = await hRes.json();
|
|
113899
|
+
return text({
|
|
113900
|
+
mode: "cold",
|
|
113901
|
+
profileId,
|
|
113902
|
+
profileName,
|
|
113903
|
+
handoffId: h2.handoffId,
|
|
113904
|
+
url: h2.url,
|
|
113905
|
+
expiresAt: h2.expiresAt,
|
|
113906
|
+
// Echoed so await_login can tell a fresh save from the state it
|
|
113907
|
+
// started in — the version bump IS the completion signal.
|
|
113908
|
+
sinceVersion: h2.version,
|
|
113909
|
+
next: `Show the url to the user, then call solari_browser_await_login({ profileId: "${profileId}", sinceVersion: ${h2.version} }).`
|
|
113910
|
+
});
|
|
113911
|
+
}
|
|
113912
|
+
const e = need(sessionId);
|
|
113913
|
+
const res = await api2(
|
|
113914
|
+
cfg,
|
|
113915
|
+
"POST",
|
|
113916
|
+
`/sessions/${encodeURIComponent(sessionId)}/handoff`,
|
|
113917
|
+
{ reason: a2.reason }
|
|
113918
|
+
);
|
|
113919
|
+
if (res.status !== 200) await apiError(res, "handoff request");
|
|
113920
|
+
const h = await res.json();
|
|
113921
|
+
try {
|
|
113922
|
+
await e.browser.close();
|
|
113923
|
+
} catch {
|
|
113924
|
+
}
|
|
113925
|
+
return text({
|
|
113926
|
+
mode: "hot",
|
|
113927
|
+
handoffId: h.handoffId,
|
|
113928
|
+
url: h.url,
|
|
113929
|
+
expiresAt: h.expiresAt,
|
|
113930
|
+
next: "Show the url to the user, then call solari_browser_await_login."
|
|
113931
|
+
});
|
|
113932
|
+
}
|
|
113933
|
+
},
|
|
113934
|
+
solari_browser_save_profile: {
|
|
113935
|
+
description: "Save this session's signed-in state (cookies + localStorage) into an existing profile, so future sessions start already logged in and no human is needed again. Pass the profileId to overwrite.\nONLY do this when the user has asked you to remember the login. It is deliberately not automatic: a saved profile holds live session cookies, which bypass 2FA \u2014 it is a credential store, and it should exist because someone chose it. If they have not said so, ask first.\nReturns what was captured (cookie count and origins) so the user can see what they just persisted.",
|
|
113936
|
+
inputSchema: {
|
|
113937
|
+
sessionId: external_exports.string(),
|
|
113938
|
+
profileId: external_exports.string()
|
|
113939
|
+
},
|
|
113940
|
+
handler: async (a2) => {
|
|
113941
|
+
need(a2.sessionId);
|
|
113942
|
+
const res = await api2(
|
|
113943
|
+
cfg,
|
|
113944
|
+
"POST",
|
|
113945
|
+
`/sessions/${encodeURIComponent(a2.sessionId)}/save-profile`,
|
|
113946
|
+
{ profileId: a2.profileId }
|
|
113947
|
+
);
|
|
113948
|
+
if (res.status !== 200) await apiError(res, "save profile");
|
|
113949
|
+
const body = await res.json();
|
|
113950
|
+
return text({
|
|
113951
|
+
...body,
|
|
113952
|
+
note: "Future sessions can use this with solari_browser_create({profileId})."
|
|
113953
|
+
});
|
|
113954
|
+
}
|
|
113955
|
+
},
|
|
113956
|
+
solari_browser_await_login: {
|
|
113957
|
+
description: "Wait for the human to finish the sign-in you requested with solari_browser_login. Blocks until they are done, the link expires, or timeoutMs elapses. Returns only a status \u2014 never anything the user typed. On 'completed' the session is yours again, on the same page, still signed in, and you can carry on where you left off. On 'expired' or 'timeout' the user did not finish: ask if they still want to, and call solari_browser_login again for a fresh link rather than retrying this.",
|
|
113958
|
+
inputSchema: {
|
|
113959
|
+
sessionId: external_exports.string().optional(),
|
|
113960
|
+
profileId: external_exports.string().optional(),
|
|
113961
|
+
sinceVersion: external_exports.number().optional(),
|
|
113962
|
+
handoffId: external_exports.string().optional(),
|
|
113963
|
+
timeoutMs: external_exports.number().optional()
|
|
113964
|
+
},
|
|
113965
|
+
handler: async (a2) => {
|
|
113966
|
+
const sessionId = typeof a2.sessionId === "string" ? a2.sessionId : "";
|
|
113967
|
+
const profileId = typeof a2.profileId === "string" ? a2.profileId : "";
|
|
113968
|
+
if (Boolean(sessionId) === Boolean(profileId)) {
|
|
113969
|
+
throw new Error(
|
|
113970
|
+
"Pass exactly one of sessionId (hot handoff) or profileId (cold handoff) \u2014 the same one solari_browser_login returned."
|
|
113971
|
+
);
|
|
113972
|
+
}
|
|
113973
|
+
const requested = typeof a2.timeoutMs === "number" ? a2.timeoutMs : 3e5;
|
|
113974
|
+
const deadline = Date.now() + Math.min(Math.max(requested, 5e3), 6e5);
|
|
113975
|
+
let status = "pending";
|
|
113976
|
+
if (profileId) {
|
|
113977
|
+
const since = typeof a2.sinceVersion === "number" ? a2.sinceVersion : 0;
|
|
113978
|
+
let version2 = since;
|
|
113979
|
+
while (Date.now() < deadline) {
|
|
113980
|
+
const res = await api2(cfg, "GET", "/profiles");
|
|
113981
|
+
if (res.status !== 200) await apiError(res, "profile status");
|
|
113982
|
+
const rows = await res.json();
|
|
113983
|
+
const row = rows.find((p) => p.id === profileId);
|
|
113984
|
+
if (!row) throw new Error(`profile ${profileId} no longer exists`);
|
|
113985
|
+
version2 = typeof row.version === "number" ? row.version : since;
|
|
113986
|
+
if (version2 > since) {
|
|
113987
|
+
status = "completed";
|
|
113988
|
+
break;
|
|
113989
|
+
}
|
|
113990
|
+
await new Promise((r) => setTimeout(r, HANDOFF_POLL_MS));
|
|
113991
|
+
}
|
|
113992
|
+
if (status !== "completed") status = "timeout";
|
|
113993
|
+
return text({
|
|
113994
|
+
mode: "cold",
|
|
113995
|
+
status,
|
|
113996
|
+
profileId,
|
|
113997
|
+
version: version2,
|
|
113998
|
+
next: status === "completed" ? `Signed in and saved. Use solari_browser_create({ profileId: "${profileId}" }) and you will start already authenticated.` : "The user did not finish. Ask if they still want to, then call solari_browser_login again for a fresh link."
|
|
113999
|
+
});
|
|
114000
|
+
}
|
|
114001
|
+
const e = need(sessionId);
|
|
114002
|
+
while (Date.now() < deadline) {
|
|
114003
|
+
const q2 = typeof a2.handoffId === "string" && a2.handoffId ? `?handoffId=${encodeURIComponent(a2.handoffId)}` : "";
|
|
114004
|
+
const res = await api2(
|
|
114005
|
+
cfg,
|
|
114006
|
+
"GET",
|
|
114007
|
+
`/sessions/${encodeURIComponent(sessionId)}/handoff${q2}`
|
|
114008
|
+
);
|
|
114009
|
+
if (res.status !== 200) await apiError(res, "handoff status");
|
|
114010
|
+
const body = await res.json();
|
|
114011
|
+
status = body.status ?? "none";
|
|
114012
|
+
if (status !== "pending") break;
|
|
114013
|
+
await new Promise((r) => setTimeout(r, HANDOFF_POLL_MS));
|
|
114014
|
+
}
|
|
114015
|
+
if (status === "pending") status = "timeout";
|
|
114016
|
+
let resumed = false;
|
|
114017
|
+
if (status === "completed" || status === "none") {
|
|
114018
|
+
try {
|
|
114019
|
+
const browser = await deps.connect(e.cdpEndpoint);
|
|
114020
|
+
const pages = await browser.pages();
|
|
114021
|
+
const page = pages.find((p) => !p.isClosed()) ?? await browser.newPage();
|
|
114022
|
+
e.browser = browser;
|
|
114023
|
+
e.page = page;
|
|
114024
|
+
resumed = true;
|
|
114025
|
+
} catch (err) {
|
|
114026
|
+
return text({
|
|
114027
|
+
status,
|
|
114028
|
+
resumed: false,
|
|
114029
|
+
note: "the sign-in finished but the session could not be re-attached: " + redact(err instanceof Error ? err.message : String(err))
|
|
114030
|
+
});
|
|
114031
|
+
}
|
|
114032
|
+
}
|
|
114033
|
+
return text({
|
|
114034
|
+
status,
|
|
114035
|
+
resumed,
|
|
114036
|
+
...resumed ? { url: e.page.url() } : {}
|
|
114037
|
+
});
|
|
114038
|
+
}
|
|
114039
|
+
},
|
|
113842
114040
|
solari_browser_navigate: {
|
|
113843
114041
|
description: "Navigate the browser session to a URL. Returns final url, title and HTTP status.",
|
|
113844
114042
|
inputSchema: { sessionId: external_exports.string(), url: external_exports.string() },
|
|
@@ -114043,6 +114241,7 @@ async function releaseAllBrowserSessions(cfg, reg, fetchApi = api) {
|
|
|
114043
114241
|
// src/server.ts
|
|
114044
114242
|
var import_meta2 = {};
|
|
114045
114243
|
var MAX_TOOL_TEXT = 3e4;
|
|
114244
|
+
var MAX_LIST_PAGES = 5;
|
|
114046
114245
|
var text2 = (o) => {
|
|
114047
114246
|
const s = typeof o === "string" ? o : JSON.stringify(o, null, 2);
|
|
114048
114247
|
const capped = s.length > MAX_TOOL_TEXT ? `${s.slice(0, MAX_TOOL_TEXT)}
|
|
@@ -114099,9 +114298,45 @@ function makeToolset(client, reg) {
|
|
|
114099
114298
|
}
|
|
114100
114299
|
},
|
|
114101
114300
|
solari_list: {
|
|
114102
|
-
description: "List the org's sandboxes.",
|
|
114103
|
-
inputSchema: {
|
|
114104
|
-
|
|
114301
|
+
description: "List the org's live VMs \u2014 BOTH sandboxes and desktops. Each entry is labelled with its `kind`; `registered:true` means this MCP session already holds a handle so the other tools accept its sessionId directly (otherwise call solari_connect first). Optional kind/state filters.",
|
|
114302
|
+
inputSchema: {
|
|
114303
|
+
kind: external_exports.enum(["sandbox", "desktop"]).optional(),
|
|
114304
|
+
state: external_exports.string().optional()
|
|
114305
|
+
},
|
|
114306
|
+
handler: async (a2) => {
|
|
114307
|
+
const kinds = a2.kind ? [a2.kind] : ["sandbox", "desktop"];
|
|
114308
|
+
const vms = [];
|
|
114309
|
+
const counts = { sandbox: 0, desktop: 0 };
|
|
114310
|
+
let truncated = false;
|
|
114311
|
+
for (const kind of kinds) {
|
|
114312
|
+
let cursor;
|
|
114313
|
+
for (let page = 0; page < MAX_LIST_PAGES; page++) {
|
|
114314
|
+
const res = await client.sandboxes.list({
|
|
114315
|
+
kind,
|
|
114316
|
+
// `state` is a closed union on the SDK; an unknown value is
|
|
114317
|
+
// simply ignored by the gateway, so pass it through.
|
|
114318
|
+
...a2.state ? { state: a2.state } : {},
|
|
114319
|
+
...cursor ? { cursor } : {}
|
|
114320
|
+
});
|
|
114321
|
+
for (const v2 of res.sandboxes ?? []) {
|
|
114322
|
+
const rec = v2;
|
|
114323
|
+
const id = rec.sandboxId ?? rec.sessionId;
|
|
114324
|
+
const k = rec.kind ?? kind;
|
|
114325
|
+
vms.push({
|
|
114326
|
+
...rec,
|
|
114327
|
+
...id ? { sessionId: id } : {},
|
|
114328
|
+
kind: k,
|
|
114329
|
+
registered: id ? reg.sessions.has(id) : false
|
|
114330
|
+
});
|
|
114331
|
+
counts[k] = (counts[k] ?? 0) + 1;
|
|
114332
|
+
}
|
|
114333
|
+
cursor = res.nextCursor;
|
|
114334
|
+
if (!cursor) break;
|
|
114335
|
+
if (page === MAX_LIST_PAGES - 1) truncated = true;
|
|
114336
|
+
}
|
|
114337
|
+
}
|
|
114338
|
+
return text2({ counts, total: vms.length, ...truncated ? { truncated } : {}, vms });
|
|
114339
|
+
}
|
|
114105
114340
|
},
|
|
114106
114341
|
solari_kill: {
|
|
114107
114342
|
description: "Destroy a session by id.",
|
|
@@ -114275,7 +114510,7 @@ function buildServerParts(client, browserCfg) {
|
|
|
114275
114510
|
apiKey: process.env.SOLARI_BROWSER_API_KEY ?? apiKey,
|
|
114276
114511
|
baseUrl: process.env.SOLARI_BROWSER_URL ?? process.env.SOLARI_BASE_URL ?? "https://api.getsolari.com"
|
|
114277
114512
|
};
|
|
114278
|
-
const server = new McpServer({ name: "solari-mcp", version:
|
|
114513
|
+
const server = new McpServer({ name: "solari-mcp", version: VERSION });
|
|
114279
114514
|
const browserReg = { sessions: /* @__PURE__ */ new Map() };
|
|
114280
114515
|
const vmReg = { sessions: /* @__PURE__ */ new Map() };
|
|
114281
114516
|
registerToolset(server, {
|
|
@@ -111026,6 +111026,9 @@ var SolariClient = class {
|
|
|
111026
111026
|
}
|
|
111027
111027
|
};
|
|
111028
111028
|
|
|
111029
|
+
// src/version.ts
|
|
111030
|
+
var VERSION = "0.3.3";
|
|
111031
|
+
|
|
111029
111032
|
// node_modules/puppeteer-core/lib/esm/puppeteer/index.js
|
|
111030
111033
|
init_index_browser();
|
|
111031
111034
|
|
|
@@ -112345,6 +112348,7 @@ var text = (o) => ({
|
|
|
112345
112348
|
var MAX_PAGE_TEXT = 3e4;
|
|
112346
112349
|
var MAX_LINKS = 200;
|
|
112347
112350
|
var CDP_DIAL_ATTEMPTS = 3;
|
|
112351
|
+
var HANDOFF_POLL_MS = 2e3;
|
|
112348
112352
|
var redact = (s) => s.replace(/wss?:\/\/[^\s"']+/gi, "[redacted-ws-url]");
|
|
112349
112353
|
async function api(cfg, method, path12, body) {
|
|
112350
112354
|
const res = await fetch(`${cfg.baseUrl}${path12}`, {
|
|
@@ -112490,7 +112494,8 @@ function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
|
|
|
112490
112494
|
browser,
|
|
112491
112495
|
page,
|
|
112492
112496
|
expiresAt: s.expiresAt,
|
|
112493
|
-
recording: Boolean(a2.recording)
|
|
112497
|
+
recording: Boolean(a2.recording),
|
|
112498
|
+
cdpEndpoint: cdp
|
|
112494
112499
|
});
|
|
112495
112500
|
} catch (err) {
|
|
112496
112501
|
await releaseBrowserSession(cfg, s.sessionId, api2).catch(() => {
|
|
@@ -112508,6 +112513,199 @@ function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
|
|
|
112508
112513
|
});
|
|
112509
112514
|
}
|
|
112510
112515
|
},
|
|
112516
|
+
solari_browser_profiles: {
|
|
112517
|
+
description: "List saved browser profiles for this account. A profile is a stored signed-in state (cookies + localStorage) that solari_browser_create({profileId}) replays, so the session starts already logged in.\nCheck here FIRST when a task will need a login: if a profile for that site already exists, use it and no human is involved at all. `version` is 1 and `populated` is false for a profile nobody has signed into yet \u2014 that one still needs solari_browser_login({profileName}).",
|
|
112518
|
+
inputSchema: {},
|
|
112519
|
+
handler: async () => {
|
|
112520
|
+
const res = await api2(cfg, "GET", "/profiles");
|
|
112521
|
+
if (res.status !== 200) await apiError(res, "list profiles");
|
|
112522
|
+
const rows = await res.json();
|
|
112523
|
+
return text({
|
|
112524
|
+
profiles: rows.map((p) => ({
|
|
112525
|
+
profileId: p.id,
|
|
112526
|
+
name: p.name,
|
|
112527
|
+
version: p.version,
|
|
112528
|
+
populated: Boolean(p.storageStateS3Key),
|
|
112529
|
+
lastUsedAt: p.lastUsedAt ?? null
|
|
112530
|
+
}))
|
|
112531
|
+
});
|
|
112532
|
+
}
|
|
112533
|
+
},
|
|
112534
|
+
solari_browser_login: {
|
|
112535
|
+
description: "Ask a HUMAN to sign in, so you never handle credentials yourself. Two ways to call it:\nHOT \u2014 pass `sessionId` when you are ALREADY on a login wall mid-task. The user gets a live view of the exact page you are on and types into it; you resume on that same page.\nCOLD \u2014 pass `profileName` when you know a task will need a login and no session is open yet. The user signs in once in a profile editor, and every later solari_browser_create({profileId}) starts already authenticated. Prefer this when you can: nobody has to be watching mid-run. The profile is created if it does not exist.\nPass exactly one of the two. Either way, SHOW THE RETURNED URL TO THE USER, then call solari_browser_await_login.\nIn the HOT case your access to that session is REVOKED while the handoff is open \u2014 deliberately, so you cannot observe what they type \u2014 and the link expires in 5 minutes. Cold links last longer and revoke nothing, because there is no session yet.\n`reason` is required and is shown to the user \u2014 say plainly which site is asking and what for, because they are being asked to type a password on your say-so.",
|
|
112536
|
+
inputSchema: {
|
|
112537
|
+
sessionId: external_exports.string().optional(),
|
|
112538
|
+
profileName: external_exports.string().optional(),
|
|
112539
|
+
reason: external_exports.string()
|
|
112540
|
+
},
|
|
112541
|
+
handler: async (a2) => {
|
|
112542
|
+
const sessionId = typeof a2.sessionId === "string" ? a2.sessionId : "";
|
|
112543
|
+
const profileName = typeof a2.profileName === "string" ? a2.profileName.trim() : "";
|
|
112544
|
+
if (Boolean(sessionId) === Boolean(profileName)) {
|
|
112545
|
+
throw new Error(
|
|
112546
|
+
"Pass exactly one of sessionId (rescue a live session) or profileName (seed a profile before you start)."
|
|
112547
|
+
);
|
|
112548
|
+
}
|
|
112549
|
+
if (profileName) {
|
|
112550
|
+
const listRes = await api2(cfg, "GET", "/profiles");
|
|
112551
|
+
if (listRes.status !== 200) await apiError(listRes, "list profiles");
|
|
112552
|
+
const rows = await listRes.json();
|
|
112553
|
+
const match = rows.find(
|
|
112554
|
+
(p) => (p.name ?? "").toLowerCase() === profileName.toLowerCase()
|
|
112555
|
+
);
|
|
112556
|
+
let profileId = match?.id ?? "";
|
|
112557
|
+
if (!profileId) {
|
|
112558
|
+
const mk = await api2(cfg, "POST", "/profiles", { name: profileName });
|
|
112559
|
+
if (mk.status !== 200 && mk.status !== 201) await apiError(mk, "create profile");
|
|
112560
|
+
profileId = (await mk.json()).id ?? "";
|
|
112561
|
+
if (!profileId) throw new Error("profile created but no id returned");
|
|
112562
|
+
}
|
|
112563
|
+
const hRes = await api2(cfg, "POST", `/profiles/${encodeURIComponent(profileId)}/login-handoff`, {
|
|
112564
|
+
reason: a2.reason
|
|
112565
|
+
});
|
|
112566
|
+
if (hRes.status !== 200) await apiError(hRes, "cold login request");
|
|
112567
|
+
const h2 = await hRes.json();
|
|
112568
|
+
return text({
|
|
112569
|
+
mode: "cold",
|
|
112570
|
+
profileId,
|
|
112571
|
+
profileName,
|
|
112572
|
+
handoffId: h2.handoffId,
|
|
112573
|
+
url: h2.url,
|
|
112574
|
+
expiresAt: h2.expiresAt,
|
|
112575
|
+
// Echoed so await_login can tell a fresh save from the state it
|
|
112576
|
+
// started in — the version bump IS the completion signal.
|
|
112577
|
+
sinceVersion: h2.version,
|
|
112578
|
+
next: `Show the url to the user, then call solari_browser_await_login({ profileId: "${profileId}", sinceVersion: ${h2.version} }).`
|
|
112579
|
+
});
|
|
112580
|
+
}
|
|
112581
|
+
const e = need(sessionId);
|
|
112582
|
+
const res = await api2(
|
|
112583
|
+
cfg,
|
|
112584
|
+
"POST",
|
|
112585
|
+
`/sessions/${encodeURIComponent(sessionId)}/handoff`,
|
|
112586
|
+
{ reason: a2.reason }
|
|
112587
|
+
);
|
|
112588
|
+
if (res.status !== 200) await apiError(res, "handoff request");
|
|
112589
|
+
const h = await res.json();
|
|
112590
|
+
try {
|
|
112591
|
+
await e.browser.close();
|
|
112592
|
+
} catch {
|
|
112593
|
+
}
|
|
112594
|
+
return text({
|
|
112595
|
+
mode: "hot",
|
|
112596
|
+
handoffId: h.handoffId,
|
|
112597
|
+
url: h.url,
|
|
112598
|
+
expiresAt: h.expiresAt,
|
|
112599
|
+
next: "Show the url to the user, then call solari_browser_await_login."
|
|
112600
|
+
});
|
|
112601
|
+
}
|
|
112602
|
+
},
|
|
112603
|
+
solari_browser_save_profile: {
|
|
112604
|
+
description: "Save this session's signed-in state (cookies + localStorage) into an existing profile, so future sessions start already logged in and no human is needed again. Pass the profileId to overwrite.\nONLY do this when the user has asked you to remember the login. It is deliberately not automatic: a saved profile holds live session cookies, which bypass 2FA \u2014 it is a credential store, and it should exist because someone chose it. If they have not said so, ask first.\nReturns what was captured (cookie count and origins) so the user can see what they just persisted.",
|
|
112605
|
+
inputSchema: {
|
|
112606
|
+
sessionId: external_exports.string(),
|
|
112607
|
+
profileId: external_exports.string()
|
|
112608
|
+
},
|
|
112609
|
+
handler: async (a2) => {
|
|
112610
|
+
need(a2.sessionId);
|
|
112611
|
+
const res = await api2(
|
|
112612
|
+
cfg,
|
|
112613
|
+
"POST",
|
|
112614
|
+
`/sessions/${encodeURIComponent(a2.sessionId)}/save-profile`,
|
|
112615
|
+
{ profileId: a2.profileId }
|
|
112616
|
+
);
|
|
112617
|
+
if (res.status !== 200) await apiError(res, "save profile");
|
|
112618
|
+
const body = await res.json();
|
|
112619
|
+
return text({
|
|
112620
|
+
...body,
|
|
112621
|
+
note: "Future sessions can use this with solari_browser_create({profileId})."
|
|
112622
|
+
});
|
|
112623
|
+
}
|
|
112624
|
+
},
|
|
112625
|
+
solari_browser_await_login: {
|
|
112626
|
+
description: "Wait for the human to finish the sign-in you requested with solari_browser_login. Blocks until they are done, the link expires, or timeoutMs elapses. Returns only a status \u2014 never anything the user typed. On 'completed' the session is yours again, on the same page, still signed in, and you can carry on where you left off. On 'expired' or 'timeout' the user did not finish: ask if they still want to, and call solari_browser_login again for a fresh link rather than retrying this.",
|
|
112627
|
+
inputSchema: {
|
|
112628
|
+
sessionId: external_exports.string().optional(),
|
|
112629
|
+
profileId: external_exports.string().optional(),
|
|
112630
|
+
sinceVersion: external_exports.number().optional(),
|
|
112631
|
+
handoffId: external_exports.string().optional(),
|
|
112632
|
+
timeoutMs: external_exports.number().optional()
|
|
112633
|
+
},
|
|
112634
|
+
handler: async (a2) => {
|
|
112635
|
+
const sessionId = typeof a2.sessionId === "string" ? a2.sessionId : "";
|
|
112636
|
+
const profileId = typeof a2.profileId === "string" ? a2.profileId : "";
|
|
112637
|
+
if (Boolean(sessionId) === Boolean(profileId)) {
|
|
112638
|
+
throw new Error(
|
|
112639
|
+
"Pass exactly one of sessionId (hot handoff) or profileId (cold handoff) \u2014 the same one solari_browser_login returned."
|
|
112640
|
+
);
|
|
112641
|
+
}
|
|
112642
|
+
const requested = typeof a2.timeoutMs === "number" ? a2.timeoutMs : 3e5;
|
|
112643
|
+
const deadline = Date.now() + Math.min(Math.max(requested, 5e3), 6e5);
|
|
112644
|
+
let status = "pending";
|
|
112645
|
+
if (profileId) {
|
|
112646
|
+
const since = typeof a2.sinceVersion === "number" ? a2.sinceVersion : 0;
|
|
112647
|
+
let version2 = since;
|
|
112648
|
+
while (Date.now() < deadline) {
|
|
112649
|
+
const res = await api2(cfg, "GET", "/profiles");
|
|
112650
|
+
if (res.status !== 200) await apiError(res, "profile status");
|
|
112651
|
+
const rows = await res.json();
|
|
112652
|
+
const row = rows.find((p) => p.id === profileId);
|
|
112653
|
+
if (!row) throw new Error(`profile ${profileId} no longer exists`);
|
|
112654
|
+
version2 = typeof row.version === "number" ? row.version : since;
|
|
112655
|
+
if (version2 > since) {
|
|
112656
|
+
status = "completed";
|
|
112657
|
+
break;
|
|
112658
|
+
}
|
|
112659
|
+
await new Promise((r) => setTimeout(r, HANDOFF_POLL_MS));
|
|
112660
|
+
}
|
|
112661
|
+
if (status !== "completed") status = "timeout";
|
|
112662
|
+
return text({
|
|
112663
|
+
mode: "cold",
|
|
112664
|
+
status,
|
|
112665
|
+
profileId,
|
|
112666
|
+
version: version2,
|
|
112667
|
+
next: status === "completed" ? `Signed in and saved. Use solari_browser_create({ profileId: "${profileId}" }) and you will start already authenticated.` : "The user did not finish. Ask if they still want to, then call solari_browser_login again for a fresh link."
|
|
112668
|
+
});
|
|
112669
|
+
}
|
|
112670
|
+
const e = need(sessionId);
|
|
112671
|
+
while (Date.now() < deadline) {
|
|
112672
|
+
const q2 = typeof a2.handoffId === "string" && a2.handoffId ? `?handoffId=${encodeURIComponent(a2.handoffId)}` : "";
|
|
112673
|
+
const res = await api2(
|
|
112674
|
+
cfg,
|
|
112675
|
+
"GET",
|
|
112676
|
+
`/sessions/${encodeURIComponent(sessionId)}/handoff${q2}`
|
|
112677
|
+
);
|
|
112678
|
+
if (res.status !== 200) await apiError(res, "handoff status");
|
|
112679
|
+
const body = await res.json();
|
|
112680
|
+
status = body.status ?? "none";
|
|
112681
|
+
if (status !== "pending") break;
|
|
112682
|
+
await new Promise((r) => setTimeout(r, HANDOFF_POLL_MS));
|
|
112683
|
+
}
|
|
112684
|
+
if (status === "pending") status = "timeout";
|
|
112685
|
+
let resumed = false;
|
|
112686
|
+
if (status === "completed" || status === "none") {
|
|
112687
|
+
try {
|
|
112688
|
+
const browser = await deps.connect(e.cdpEndpoint);
|
|
112689
|
+
const pages = await browser.pages();
|
|
112690
|
+
const page = pages.find((p) => !p.isClosed()) ?? await browser.newPage();
|
|
112691
|
+
e.browser = browser;
|
|
112692
|
+
e.page = page;
|
|
112693
|
+
resumed = true;
|
|
112694
|
+
} catch (err) {
|
|
112695
|
+
return text({
|
|
112696
|
+
status,
|
|
112697
|
+
resumed: false,
|
|
112698
|
+
note: "the sign-in finished but the session could not be re-attached: " + redact(err instanceof Error ? err.message : String(err))
|
|
112699
|
+
});
|
|
112700
|
+
}
|
|
112701
|
+
}
|
|
112702
|
+
return text({
|
|
112703
|
+
status,
|
|
112704
|
+
resumed,
|
|
112705
|
+
...resumed ? { url: e.page.url() } : {}
|
|
112706
|
+
});
|
|
112707
|
+
}
|
|
112708
|
+
},
|
|
112511
112709
|
solari_browser_navigate: {
|
|
112512
112710
|
description: "Navigate the browser session to a URL. Returns final url, title and HTTP status.",
|
|
112513
112711
|
inputSchema: { sessionId: external_exports.string(), url: external_exports.string() },
|
|
@@ -112712,6 +112910,7 @@ async function releaseAllBrowserSessions(cfg, reg, fetchApi = api) {
|
|
|
112712
112910
|
// src/server.ts
|
|
112713
112911
|
var import_meta2 = {};
|
|
112714
112912
|
var MAX_TOOL_TEXT = 3e4;
|
|
112913
|
+
var MAX_LIST_PAGES = 5;
|
|
112715
112914
|
var text2 = (o) => {
|
|
112716
112915
|
const s = typeof o === "string" ? o : JSON.stringify(o, null, 2);
|
|
112717
112916
|
const capped = s.length > MAX_TOOL_TEXT ? `${s.slice(0, MAX_TOOL_TEXT)}
|
|
@@ -112768,9 +112967,45 @@ function makeToolset(client, reg) {
|
|
|
112768
112967
|
}
|
|
112769
112968
|
},
|
|
112770
112969
|
solari_list: {
|
|
112771
|
-
description: "List the org's sandboxes.",
|
|
112772
|
-
inputSchema: {
|
|
112773
|
-
|
|
112970
|
+
description: "List the org's live VMs \u2014 BOTH sandboxes and desktops. Each entry is labelled with its `kind`; `registered:true` means this MCP session already holds a handle so the other tools accept its sessionId directly (otherwise call solari_connect first). Optional kind/state filters.",
|
|
112971
|
+
inputSchema: {
|
|
112972
|
+
kind: external_exports.enum(["sandbox", "desktop"]).optional(),
|
|
112973
|
+
state: external_exports.string().optional()
|
|
112974
|
+
},
|
|
112975
|
+
handler: async (a2) => {
|
|
112976
|
+
const kinds = a2.kind ? [a2.kind] : ["sandbox", "desktop"];
|
|
112977
|
+
const vms = [];
|
|
112978
|
+
const counts = { sandbox: 0, desktop: 0 };
|
|
112979
|
+
let truncated = false;
|
|
112980
|
+
for (const kind of kinds) {
|
|
112981
|
+
let cursor;
|
|
112982
|
+
for (let page = 0; page < MAX_LIST_PAGES; page++) {
|
|
112983
|
+
const res = await client.sandboxes.list({
|
|
112984
|
+
kind,
|
|
112985
|
+
// `state` is a closed union on the SDK; an unknown value is
|
|
112986
|
+
// simply ignored by the gateway, so pass it through.
|
|
112987
|
+
...a2.state ? { state: a2.state } : {},
|
|
112988
|
+
...cursor ? { cursor } : {}
|
|
112989
|
+
});
|
|
112990
|
+
for (const v2 of res.sandboxes ?? []) {
|
|
112991
|
+
const rec = v2;
|
|
112992
|
+
const id = rec.sandboxId ?? rec.sessionId;
|
|
112993
|
+
const k = rec.kind ?? kind;
|
|
112994
|
+
vms.push({
|
|
112995
|
+
...rec,
|
|
112996
|
+
...id ? { sessionId: id } : {},
|
|
112997
|
+
kind: k,
|
|
112998
|
+
registered: id ? reg.sessions.has(id) : false
|
|
112999
|
+
});
|
|
113000
|
+
counts[k] = (counts[k] ?? 0) + 1;
|
|
113001
|
+
}
|
|
113002
|
+
cursor = res.nextCursor;
|
|
113003
|
+
if (!cursor) break;
|
|
113004
|
+
if (page === MAX_LIST_PAGES - 1) truncated = true;
|
|
113005
|
+
}
|
|
113006
|
+
}
|
|
113007
|
+
return text2({ counts, total: vms.length, ...truncated ? { truncated } : {}, vms });
|
|
113008
|
+
}
|
|
112774
113009
|
},
|
|
112775
113010
|
solari_kill: {
|
|
112776
113011
|
description: "Destroy a session by id.",
|
|
@@ -112944,7 +113179,7 @@ function buildServerParts(client, browserCfg) {
|
|
|
112944
113179
|
apiKey: process.env.SOLARI_BROWSER_API_KEY ?? apiKey,
|
|
112945
113180
|
baseUrl: process.env.SOLARI_BROWSER_URL ?? process.env.SOLARI_BASE_URL ?? "https://api.getsolari.com"
|
|
112946
113181
|
};
|
|
112947
|
-
const server = new McpServer({ name: "solari-mcp", version:
|
|
113182
|
+
const server = new McpServer({ name: "solari-mcp", version: VERSION });
|
|
112948
113183
|
const browserReg = { sessions: /* @__PURE__ */ new Map() };
|
|
112949
113184
|
const vmReg = { sessions: /* @__PURE__ */ new Map() };
|
|
112950
113185
|
registerToolset(server, {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const VERSION = "0.3.3";
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// The single source of truth for the version this server reports to MCP
|
|
2
|
+
// clients (`initialize` → serverInfo.version).
|
|
3
|
+
//
|
|
4
|
+
// It CANNOT be imported straight from package.json: tsconfig pins
|
|
5
|
+
// `rootDir: "src"`, so `import "../package.json"` is outside the root and tsc
|
|
6
|
+
// refuses it; widening rootDir would emit `dist/src/*.js` and break the `bin`
|
|
7
|
+
// path + the esbuild bundles. So it is a literal here — and
|
|
8
|
+
// `test/version.test.mjs` FAILS the build if it ever drifts from package.json.
|
|
9
|
+
//
|
|
10
|
+
// KEEP IN SYNC WITH sdk/mcp/package.json "version".
|
|
11
|
+
export const VERSION = "0.3.3";
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solarisdk/mcp",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "Model Context Protocol server for the Solari cloud browser, sandboxes + desktops
|
|
3
|
+
"version": "0.3.3",
|
|
4
|
+
"description": "Model Context Protocol server for the Solari cloud browser, sandboxes + desktops — drive them from Claude Desktop/Cowork, Claude Code, Cursor, etc.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"solari-mcp": "./dist/cli.js"
|