@solarisdk/mcp 0.4.4 → 0.4.6

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/dist/server.js CHANGED
@@ -20,6 +20,24 @@ import { makeBrowserToolset, releaseAllBrowserSessions, } from "./browser.js";
20
20
  const MAX_TOOL_TEXT = 30_000;
21
21
  // Pages of `GET /sandboxes` solari_list will follow per kind (100 rows each).
22
22
  const MAX_LIST_PAGES = 5;
23
+ // The four park/checkpoint tool args, flat on the wire-facing tool schemas
24
+ // (see the create tools below), assembled into the nested `lifecycle` shape
25
+ // the gateway actually accepts. Returns undefined -- not an empty object --
26
+ // when the caller set none of them, so a create that never mentions lifecycle
27
+ // does not send `lifecycle: {}` (the gateway defaults onTimeout/autoResume
28
+ // itself; sending an empty object would just be noise on the wire).
29
+ function buildLifecycle(a) {
30
+ const lc = {};
31
+ if (a.parkAfterMs)
32
+ lc.parkAfterMs = a.parkAfterMs;
33
+ if (a.parkMaxMs)
34
+ lc.parkMaxMs = a.parkMaxMs;
35
+ if (a.hibernateAfterParkedMs)
36
+ lc.hibernateAfterParkedMs = a.hibernateAfterParkedMs;
37
+ if (a.checkpointEveryMs)
38
+ lc.checkpointEveryMs = a.checkpointEveryMs;
39
+ return Object.keys(lc).length > 0 ? lc : undefined;
40
+ }
23
41
  const text = (o) => {
24
42
  const s = typeof o === "string" ? o : JSON.stringify(o, null, 2);
25
43
  const capped = s.length > MAX_TOOL_TEXT
@@ -27,17 +45,58 @@ const text = (o) => {
27
45
  : s;
28
46
  return { content: [{ type: "text", text: capped }] };
29
47
  };
48
+ // Rebuild a registry Entry for `id` by asking the gateway what it is and
49
+ // re-attaching. This is the same sequence solari_connect performs by hand, and
50
+ // it is what makes the registry a CACHE rather than authoritative state: the
51
+ // gateway already knows every session, and the client already carries the id
52
+ // (create tools return it, every other tool takes it).
53
+ async function rehydrate(client, reg, id) {
54
+ let kind = reg.sessions.get(id)?.kind ?? "sandbox";
55
+ let state;
56
+ try {
57
+ const view = await client.sandboxes.get(id);
58
+ if (view?.kind === "desktop" || view?.kind === "sandbox")
59
+ kind = view.kind;
60
+ state = view?.state;
61
+ }
62
+ catch {
63
+ // The gateway is the authority on existence; if it cannot be reached we
64
+ // fall through to connect() and let ITS error surface, rather than
65
+ // inventing an "unknown sessionId" that hides a gateway outage.
66
+ }
67
+ const handle = kind === "desktop" ? await client.desktops.connect(id) : await client.sandboxes.connect(id);
68
+ // desktops.connect() auto-resumes; sandboxes.connect() does not.
69
+ if (kind === "sandbox" && state === "paused" && typeof handle.resume === "function") {
70
+ await handle.resume();
71
+ }
72
+ // commands: [] is correct, not a loss. The array only exists to keep the
73
+ // rejecting exit promise of a background command owned; no tool ever reads
74
+ // it back, so a rebuilt Entry owes nothing to the pod that started one.
75
+ const e = { kind, handle, commands: [] };
76
+ reg.sessions.set(id, e);
77
+ return e;
78
+ }
30
79
  export function makeToolset(client, reg) {
31
- const need = (id) => {
80
+ // A MISS IS NOT AN ERROR — it is a cold cache. Before 2026-09-21 this threw
81
+ // `unknown sessionId`, which is why the hosted connector could only ever run
82
+ // one replica: a request that landed on a pod which had not served the
83
+ // create call was indistinguishable from a request for a session that never
84
+ // existed. Rebuilding on demand is what lets any pod serve any request.
85
+ const need = async (id) => {
32
86
  const e = reg.sessions.get(id);
33
- if (!e)
34
- throw new Error(`unknown sessionId: ${id}`);
35
- return e;
87
+ if (e)
88
+ return e;
89
+ return rehydrate(client, reg, id);
36
90
  };
37
91
  // A paused session refuses the /control upgrade with 409, so resume it and
38
92
  // retry rather than surfacing an opaque websocket error.
93
+ //
94
+ // A PARKED session needs nothing here: park keeps the slot, the host and the
95
+ // routing, and the gateway unparks on the /control upgrade itself (wake-on-
96
+ // touch). The connect below just works, a few hundred milliseconds slower.
97
+ // Do not add an unpark call -- it would be a redundant round trip.
39
98
  const live = async (id) => {
40
- const e = need(id);
99
+ const e = await need(id);
41
100
  if (e.handle.connected)
42
101
  return e;
43
102
  try {
@@ -60,12 +119,52 @@ export function makeToolset(client, reg) {
60
119
  return {
61
120
  solari_sandbox_create: {
62
121
  description: "Create a headless sandbox (microVM). Returns its sessionId.",
63
- inputSchema: { template: z.string().optional(), cpu: z.number().optional(), memMb: z.number().optional() },
122
+ inputSchema: {
123
+ template: z.string().optional(),
124
+ cpu: z.number().optional(),
125
+ memMb: z.number().optional(),
126
+ // Kept FLAT rather than a nested `lifecycle` object, matching every
127
+ // other tool schema in this file -- a flat schema is what an LLM
128
+ // tool-calling interface parses most reliably. Mapped into the
129
+ // gateway's nested `lifecycle` shape only inside the handler.
130
+ ttlSeconds: z
131
+ .number()
132
+ .optional()
133
+ .describe("Legacy idle-window fallback in seconds, only used when the gateway's default " +
134
+ "timeout is not already set by another field. Usually unnecessary."),
135
+ isolation: z
136
+ .enum(["shared", "dedicated", "hardened"])
137
+ .optional()
138
+ .describe("VM isolation tier. \"shared\" runs inside a VM shared with your other shared " +
139
+ "sandboxes (smaller footprint, faster start). \"dedicated\" (default) is your " +
140
+ "own VM. \"hardened\" is your own VM with outbound network blocked -- verified " +
141
+ "on staging, not yet on prod; refused if the assigned host doesn't support it."),
142
+ parkAfterMs: z
143
+ .number()
144
+ .optional()
145
+ .describe("Auto-park (freeze, keep the slot) after this many ms of no activity."),
146
+ parkMaxMs: z
147
+ .number()
148
+ .optional()
149
+ .describe("Wake a parked session unconditionally after this many ms, even if idle."),
150
+ hibernateAfterParkedMs: z
151
+ .number()
152
+ .optional()
153
+ .describe("Escalate a parked session to a full hibernate after this many ms parked."),
154
+ checkpointEveryMs: z
155
+ .number()
156
+ .optional()
157
+ .describe("Take a durable checkpoint at most this often while the session runs."),
158
+ },
64
159
  handler: async (a) => {
160
+ const lifecycle = buildLifecycle(a);
65
161
  const sbx = await client.sandboxes.create({
66
162
  ...(a.template ? { template: a.template } : {}),
67
163
  ...(a.cpu ? { cpu: a.cpu } : {}),
68
164
  ...(a.memMb ? { memMb: a.memMb } : {}),
165
+ ...(a.ttlSeconds ? { ttlSeconds: a.ttlSeconds } : {}),
166
+ ...(a.isolation ? { isolation: a.isolation } : {}),
167
+ ...(lifecycle ? { lifecycle: lifecycle } : {}),
69
168
  });
70
169
  reg.sessions.set(sbx.sandboxId, { kind: "sandbox", handle: sbx, commands: [] });
71
170
  return text({ sessionId: sbx.sandboxId });
@@ -73,11 +172,46 @@ export function makeToolset(client, reg) {
73
172
  },
74
173
  solari_desktop_create: {
75
174
  description: "Create a GUI desktop (microVM). Returns sessionId + streamUrl (noVNC).",
76
- inputSchema: { template: z.string().optional(), resolution: z.string().optional() },
175
+ inputSchema: {
176
+ template: z.string().optional(),
177
+ resolution: z.string().optional(),
178
+ ttlSeconds: z
179
+ .number()
180
+ .optional()
181
+ .describe("Legacy idle-window fallback in seconds, only used when the gateway's default " +
182
+ "timeout is not already set by another field. Usually unnecessary."),
183
+ isolation: z
184
+ .enum(["dedicated", "hardened"])
185
+ .optional()
186
+ .describe("VM isolation tier. \"dedicated\" (default) is your own VM. \"hardened\" is your " +
187
+ "own VM with outbound network blocked -- verified on staging, not yet on prod; " +
188
+ "refused if the assigned host doesn't support it. \"shared\" is sandbox-only " +
189
+ "(a desktop's display is per-VM) and is not offered here."),
190
+ parkAfterMs: z
191
+ .number()
192
+ .optional()
193
+ .describe("Auto-park (freeze, keep the slot) after this many ms of no activity."),
194
+ parkMaxMs: z
195
+ .number()
196
+ .optional()
197
+ .describe("Wake a parked session unconditionally after this many ms, even if idle."),
198
+ hibernateAfterParkedMs: z
199
+ .number()
200
+ .optional()
201
+ .describe("Escalate a parked session to a full hibernate after this many ms parked."),
202
+ checkpointEveryMs: z
203
+ .number()
204
+ .optional()
205
+ .describe("Take a durable checkpoint at most this often while the session runs."),
206
+ },
77
207
  handler: async (a) => {
208
+ const lifecycle = buildLifecycle(a);
78
209
  const d = await client.desktops.create({
79
210
  ...(a.template ? { template: a.template } : {}),
80
211
  ...(a.resolution ? { resolution: a.resolution } : {}),
212
+ ...(a.ttlSeconds ? { ttlSeconds: a.ttlSeconds } : {}),
213
+ ...(a.isolation ? { isolation: a.isolation } : {}),
214
+ ...(lifecycle ? { lifecycle: lifecycle } : {}),
81
215
  });
82
216
  reg.sessions.set(d.sessionId, { kind: "desktop", handle: d, commands: [] });
83
217
  return text({ sessionId: d.sessionId, streamUrl: d.streamUrl });
@@ -142,7 +276,7 @@ export function makeToolset(client, reg) {
142
276
  description: "Destroy a session by id.",
143
277
  inputSchema: { sessionId: z.string() },
144
278
  handler: async (a) => {
145
- const e = need(a.sessionId);
279
+ const e = await need(a.sessionId);
146
280
  await e.handle.kill();
147
281
  reg.sessions.delete(a.sessionId);
148
282
  return text({ ok: true });
@@ -275,6 +409,62 @@ export function makeToolset(client, reg) {
275
409
  return text({ ok: true });
276
410
  },
277
411
  },
412
+ solari_mouse_down: {
413
+ description: "Press and HOLD a mouse button at (x, y) — the start of a drag (e.g. grab a window " +
414
+ "title bar). Follow with solari_mouse_move to drag while held, then solari_mouse_up " +
415
+ "to release. For a simple one-shot drag prefer solari_drag.",
416
+ inputSchema: {
417
+ sessionId: z.string(),
418
+ x: z.number(),
419
+ y: z.number(),
420
+ button: z.enum(["left", "right", "middle"]).optional(),
421
+ },
422
+ handler: async (a) => {
423
+ const e = await desktop(a.sessionId);
424
+ await e.handle.mouse.down(a.x, a.y, a.button ?? "left");
425
+ return text({ ok: true });
426
+ },
427
+ },
428
+ solari_mouse_move: {
429
+ description: "Move the pointer to (x, y). While a button is held (solari_mouse_down) this drags.",
430
+ inputSchema: { sessionId: z.string(), x: z.number(), y: z.number() },
431
+ handler: async (a) => {
432
+ const e = await desktop(a.sessionId);
433
+ await e.handle.mouse.move(a.x, a.y);
434
+ return text({ ok: true });
435
+ },
436
+ },
437
+ solari_mouse_up: {
438
+ description: "Release the held mouse button at (x, y) — the end of a drag started with solari_mouse_down.",
439
+ inputSchema: {
440
+ sessionId: z.string(),
441
+ x: z.number(),
442
+ y: z.number(),
443
+ button: z.enum(["left", "right", "middle"]).optional(),
444
+ },
445
+ handler: async (a) => {
446
+ const e = await desktop(a.sessionId);
447
+ await e.handle.mouse.up(a.x, a.y, a.button ?? "left");
448
+ return text({ ok: true });
449
+ },
450
+ },
451
+ solari_drag: {
452
+ description: "Drag in one call: press at (fromX, fromY), move to (toX, toY), release. " +
453
+ "Good for moving windows, sliders, and drag-and-drop.",
454
+ inputSchema: {
455
+ sessionId: z.string(),
456
+ fromX: z.number(),
457
+ fromY: z.number(),
458
+ toX: z.number(),
459
+ toY: z.number(),
460
+ button: z.enum(["left", "right", "middle"]).optional(),
461
+ },
462
+ handler: async (a) => {
463
+ const e = await desktop(a.sessionId);
464
+ await e.handle.mouse.drag({ x: a.fromX, y: a.fromY }, { x: a.toX, y: a.toY }, a.button ?? "left");
465
+ return text({ ok: true });
466
+ },
467
+ },
278
468
  solari_type: {
279
469
  description: "Type text into the focused desktop element.",
280
470
  inputSchema: { sessionId: z.string(), text: z.string() },
@@ -331,7 +521,7 @@ export async function closeAllVmSessions(reg) {
331
521
  }
332
522
  }));
333
523
  }
334
- export function buildServerParts(client, browserCfg) {
524
+ export function buildServerParts(client, browserCfg, regs) {
335
525
  const apiKey = browserCfg?.apiKey ?? process.env.SOLARI_API_KEY ?? "";
336
526
  const c = client ??
337
527
  new SolariClient({
@@ -349,8 +539,12 @@ export function buildServerParts(client, browserCfg) {
349
539
  "https://api.getsolari.com",
350
540
  };
351
541
  const server = new McpServer({ name: "solari-mcp", version: VERSION });
352
- const browserReg = { sessions: new Map() };
353
- const vmReg = { sessions: new Map() };
542
+ // Registries may be SUPPLIED by the caller. The hosted HTTP transport is
543
+ // stateless (a fresh McpServer per request), so it keeps one pair of
544
+ // registries per API key and passes them in — otherwise every request would
545
+ // start with a cold cache and re-attach every handle from the gateway.
546
+ const browserReg = regs?.browserReg ?? { sessions: new Map() };
547
+ const vmReg = regs?.vmReg ?? { sessions: new Map() };
354
548
  registerToolset(server, {
355
549
  ...makeToolset(c, vmReg),
356
550
  ...makeBrowserToolset(bCfg, browserReg),
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.4.4";
1
+ export declare const VERSION = "0.4.6";
package/dist/version.js CHANGED
@@ -8,4 +8,4 @@
8
8
  // `test/version.test.mjs` FAILS the build if it ever drifts from package.json.
9
9
  //
10
10
  // KEEP IN SYNC WITH sdk/mcp/package.json "version".
11
- export const VERSION = "0.4.4";
11
+ export const VERSION = "0.4.6";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solarisdk/mcp",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "Model Context Protocol server for the Solari cloud browser, sandboxes + desktops \u2014 drive them from Claude Desktop/Cowork, Claude Code, Cursor, etc.",
5
5
  "type": "module",
6
6
  "bin": {