@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 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
- inputSchema: {},
86
- handler: async () => text(await client.sandboxes.list()),
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: "0.3.0" });
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
- handler: async () => text2(await client.sandboxes.list())
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: "0.3.0" });
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
- handler: async () => text2(await client.sandboxes.list())
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: "0.3.0" });
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";
@@ -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.1",
4
- "description": "Model Context Protocol server for the Solari cloud browser, sandboxes + desktops \u2014 drive them from Claude Desktop/Cowork, Claude Code, Cursor, etc.",
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"