@solarisdk/mcp 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/http.js CHANGED
@@ -11,53 +11,75 @@
11
11
  // SOLARI_BASE_URL sandbox/desktop gateway (default https://api.getsolari.com)
12
12
  // SOLARI_BROWSER_URL browser gateway (default = SOLARI_BASE_URL)
13
13
  // SESSION_IDLE_MS evict MCP sessions idle longer than this (default 30 min)
14
+ // MAX_SESSIONS global cap on live MCP sessions (default 500)
15
+ // MAX_SESSIONS_PER_KEY per-API-key cap (default 20)
14
16
  import { createServer } from "node:http";
15
17
  import { createHash, randomUUID } from "node:crypto";
16
18
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
17
- import { buildServerParts } from "./server.js";
19
+ import { buildServerParts, closeAllVmSessions } from "./server.js";
20
+ import { releaseAllBrowserSessions } from "./browser.js";
18
21
  const PORT = Number(process.env.PORT ?? 8080);
19
22
  const IDLE_MS = Number(process.env.SESSION_IDLE_MS ?? 30 * 60 * 1000);
20
- const BROWSER_URL = process.env.SOLARI_BROWSER_URL ?? process.env.SOLARI_BASE_URL ?? "https://api.getsolari.com";
23
+ const MAX_SESSIONS = Number(process.env.MAX_SESSIONS ?? 500);
24
+ const MAX_PER_KEY = Number(process.env.MAX_SESSIONS_PER_KEY ?? 20);
25
+ const MAX_BODY_BYTES = 4 * 1024 * 1024;
26
+ const DESKTOP_URL = process.env.SOLARI_BASE_URL ?? "https://api.getsolari.com";
27
+ const BROWSER_URL = process.env.SOLARI_BROWSER_URL ?? DESKTOP_URL;
21
28
  const sessions = new Map();
22
29
  const hash = (key) => createHash("sha256").update(key).digest("hex");
30
+ const countForKey = (h) => [...sessions.values()].filter((s) => s.keyHash === h).length;
23
31
  // Cheap upfront key validation (the gateways are the real authority; this just
24
32
  // gives a clean 401 at connect time instead of failures on the first tool
25
- // call). Positive results cached briefly to absorb client reconnect storms.
26
- const keyOkUntil = new Map();
33
+ // call). Results are cached both ways and coalesced per key so a reconnect
34
+ // storm can't amplify into the prod auth path.
35
+ const keyCache = new Map();
36
+ const inflight = new Map();
37
+ async function probe(url, key) {
38
+ try {
39
+ const res = await fetch(url, {
40
+ headers: { authorization: `Bearer ${key}` },
41
+ signal: AbortSignal.timeout(5_000),
42
+ });
43
+ if (res.ok)
44
+ return "ok";
45
+ if (res.status === 401 || res.status === 403)
46
+ return "reject";
47
+ return "unknown";
48
+ }
49
+ catch {
50
+ return "unknown";
51
+ }
52
+ }
27
53
  async function validateKey(key) {
28
54
  const h = hash(key);
29
- const until = keyOkUntil.get(h);
30
- if (until && until > Date.now())
31
- return true;
32
- // Prod's browser and desktop gateways have separate key stores — a key is
33
- // acceptable if EITHER accepts it (its tool calls on the other surface will
34
- // fail with that gateway's own auth error).
35
- const probes = [
36
- `${BROWSER_URL}/profiles`,
37
- `${process.env.SOLARI_BASE_URL ?? "https://api.getsolari.com"}/sandboxes?limit=1`,
38
- ];
39
- let sawDefinitiveReject = false;
40
- for (const url of probes) {
41
- try {
42
- const res = await fetch(url, {
43
- headers: { authorization: `Bearer ${key}` },
44
- signal: AbortSignal.timeout(10_000),
45
- });
46
- if (res.ok) {
47
- keyOkUntil.set(h, Date.now() + 60_000);
48
- return true;
49
- }
50
- if (res.status === 401 || res.status === 403)
51
- sawDefinitiveReject = true;
52
- // Other statuses: inconclusive (gateway hiccup) — keep probing.
55
+ const hit = keyCache.get(h);
56
+ if (hit && hit.until > Date.now())
57
+ return hit.ok;
58
+ const existing = inflight.get(h);
59
+ if (existing)
60
+ return existing;
61
+ const p = (async () => {
62
+ // Prod's browser and desktop gateways have separate key stores — a key is
63
+ // acceptable if EITHER accepts it. Probe both concurrently.
64
+ const [b, d] = await Promise.all([
65
+ probe(`${BROWSER_URL}/profiles`, key),
66
+ probe(`${DESKTOP_URL}/sandboxes?limit=1`, key),
67
+ ]);
68
+ if (b === "ok" || d === "ok") {
69
+ keyCache.set(h, { ok: true, until: Date.now() + 60_000 });
70
+ return true;
53
71
  }
54
- catch {
55
- /* transient inconclusive */
72
+ if (b === "reject" || d === "reject") {
73
+ // Definitive no from a gateway that answered.
74
+ keyCache.set(h, { ok: false, until: Date.now() + 30_000 });
75
+ return false;
56
76
  }
57
- }
58
- // Reject only when at least one gateway definitively said no and none said
59
- // yes; if everything was inconclusive, don't lock customers out on our blip.
60
- return !sawDefinitiveReject;
77
+ // Everything inconclusive (our blip, not the customer's fault): allow, but
78
+ // don't cache, so we re-check on the next connect.
79
+ return true;
80
+ })().finally(() => inflight.delete(h));
81
+ inflight.set(h, p);
82
+ return p;
61
83
  }
62
84
  function bearer(req) {
63
85
  const m = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? "");
@@ -67,9 +89,17 @@ function json(res, status, body) {
67
89
  res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body));
68
90
  }
69
91
  async function readBody(req) {
92
+ const declared = Number(req.headers["content-length"] ?? 0);
93
+ if (declared > MAX_BODY_BYTES)
94
+ throw Object.assign(new Error("body too large"), { status: 413 });
70
95
  const chunks = [];
71
- for await (const c of req)
96
+ let size = 0;
97
+ for await (const c of req) {
98
+ size += c.length;
99
+ if (size > MAX_BODY_BYTES)
100
+ throw Object.assign(new Error("body too large"), { status: 413 });
72
101
  chunks.push(c);
102
+ }
73
103
  const raw = Buffer.concat(chunks).toString("utf8");
74
104
  if (!raw)
75
105
  return undefined;
@@ -80,16 +110,15 @@ async function evict(id, why) {
80
110
  if (!s)
81
111
  return;
82
112
  sessions.delete(id);
83
- // Disconnect any live puppeteer handles (the cloud sessions themselves are
84
- // governed by their own idle/pause TTLs we only drop our sockets).
85
- for (const e of s.browserReg.sessions.values()) {
86
- try {
87
- await e.browser.disconnect();
88
- }
89
- catch {
90
- /* already gone */
91
- }
92
- }
113
+ // Actually RELEASE the customer's cloud resources. Browser sessions hold a
114
+ // concurrency-limited pool slot until their hard TTL, and an open VM control
115
+ // channel pins the VM "active" so it never idle-pauses — both bill until we
116
+ // let go, and abandoning the MCP session is the normal end-of-conversation
117
+ // path (models rarely call the close tools).
118
+ await Promise.allSettled([
119
+ releaseAllBrowserSessions(s.browserCfg, s.browserReg),
120
+ closeAllVmSessions(s.vmReg),
121
+ ]);
93
122
  try {
94
123
  await s.transport.close();
95
124
  }
@@ -98,12 +127,13 @@ async function evict(id, why) {
98
127
  }
99
128
  console.error(`mcp session ${id} evicted (${why})`);
100
129
  }
101
- setInterval(() => {
130
+ const idleSweep = setInterval(() => {
102
131
  const now = Date.now();
103
132
  for (const [id, s] of sessions)
104
133
  if (now - s.lastSeen > IDLE_MS)
105
134
  void evict(id, "idle");
106
- }, 60_000).unref();
135
+ }, 60_000);
136
+ idleSweep.unref();
107
137
  async function handleMcp(req, res) {
108
138
  const key = bearer(req);
109
139
  if (!key || !key.startsWith("slr_live_")) {
@@ -135,20 +165,33 @@ async function handleMcp(req, res) {
135
165
  json(res, 401, { error: "invalid Solari API key" });
136
166
  return;
137
167
  }
168
+ const keyHash = hash(key);
169
+ if (sessions.size >= MAX_SESSIONS) {
170
+ json(res, 503, { error: "server at session capacity, retry shortly" });
171
+ return;
172
+ }
173
+ if (countForKey(keyHash) >= MAX_PER_KEY) {
174
+ json(res, 429, { error: `too many concurrent MCP sessions for this API key (max ${MAX_PER_KEY})` });
175
+ return;
176
+ }
138
177
  const body = await readBody(req);
139
- const { server, browserReg } = buildServerParts(undefined, {
140
- apiKey: key,
141
- baseUrl: BROWSER_URL,
142
- });
178
+ const parts = buildServerParts(undefined, { apiKey: key, baseUrl: BROWSER_URL });
143
179
  const transport = new StreamableHTTPServerTransport({
144
180
  sessionIdGenerator: randomUUID,
145
181
  onsessioninitialized: (id) => {
146
- sessions.set(id, { transport, browserReg, keyHash: hash(key), lastSeen: Date.now() });
147
- console.error(`mcp session ${id} started (key …${key.slice(-4)})`);
182
+ sessions.set(id, {
183
+ transport,
184
+ browserReg: parts.browserReg,
185
+ browserCfg: parts.browserCfg,
186
+ vmReg: parts.vmReg,
187
+ keyHash,
188
+ lastSeen: Date.now(),
189
+ });
190
+ console.error(`mcp session ${id} started (key ${keyHash.slice(0, 8)})`);
148
191
  },
149
192
  onsessionclosed: (id) => void evict(id, "client closed"),
150
193
  });
151
- await server.connect(transport);
194
+ await parts.server.connect(transport);
152
195
  await transport.handleRequest(req, res, body);
153
196
  }
154
197
  const httpServer = createServer((req, res) => {
@@ -159,9 +202,11 @@ const httpServer = createServer((req, res) => {
159
202
  }
160
203
  if (url === "/mcp") {
161
204
  handleMcp(req, res).catch((err) => {
162
- console.error("mcp request failed:", err);
205
+ const status = err.status ?? 500;
206
+ if (status !== 413)
207
+ console.error("mcp request failed:", err);
163
208
  if (!res.headersSent)
164
- json(res, 500, { error: "internal error" });
209
+ json(res, status, { error: status === 413 ? "body too large" : "internal error" });
165
210
  else
166
211
  res.end();
167
212
  });
@@ -169,7 +214,29 @@ const httpServer = createServer((req, res) => {
169
214
  }
170
215
  json(res, 404, { error: "not found (use /mcp)" });
171
216
  });
217
+ // A stray rejection must never take down a task serving many tenants.
218
+ process.on("unhandledRejection", (err) => {
219
+ console.error("solari-mcp: unhandled rejection (ignored):", err);
220
+ });
221
+ let shuttingDown = false;
222
+ async function shutdown(sig) {
223
+ if (shuttingDown)
224
+ return;
225
+ shuttingDown = true;
226
+ console.error(`solari-mcp: ${sig} — draining ${sessions.size} session(s)`);
227
+ httpServer.close();
228
+ clearInterval(idleSweep);
229
+ // Release every tenant's cloud resources; without this an ECS rollout leaks
230
+ // one pool slot + one pinned VM per live conversation.
231
+ await Promise.race([
232
+ Promise.allSettled([...sessions.keys()].map((id) => evict(id, sig))),
233
+ new Promise((r) => setTimeout(r, 20_000)),
234
+ ]);
235
+ process.exit(0);
236
+ }
237
+ for (const sig of ["SIGTERM", "SIGINT"]) {
238
+ process.on(sig, () => void shutdown(sig));
239
+ }
172
240
  httpServer.listen(PORT, () => {
173
- console.error(`solari-mcp http listening on :${PORT} (browser gw ${BROWSER_URL}, ` +
174
- `desktop gw ${process.env.SOLARI_BASE_URL ?? "https://api.getsolari.com"})`);
241
+ console.error(`solari-mcp http listening on :${PORT} (browser gw ${BROWSER_URL}, desktop gw ${DESKTOP_URL})`);
175
242
  });
package/dist/server.d.ts CHANGED
@@ -7,6 +7,7 @@ type Kind = "sandbox" | "desktop";
7
7
  interface Entry {
8
8
  kind: Kind;
9
9
  handle: any;
10
+ commands: any[];
10
11
  }
11
12
  export interface Registry {
12
13
  sessions: Map<string, Entry>;
@@ -32,7 +33,19 @@ export declare function registerToolset(server: McpServer, toolset: Record<strin
32
33
  export interface ServerParts {
33
34
  server: McpServer;
34
35
  browserReg: BrowserRegistry;
36
+ /** Sandbox/desktop registry — the caller must close these on teardown. */
37
+ vmReg: Registry;
38
+ browserCfg: BrowserConfig;
35
39
  }
40
+ /**
41
+ * Drop our control-channel sockets for every sandbox/desktop in a registry.
42
+ *
43
+ * This is required, not cosmetic: the gateway treats a session with any open
44
+ * control/stream connection as active and never expires it, so leaving the
45
+ * channel open defeats idle-pause and the VM bills indefinitely. We close
46
+ * (not kill) so the customer keeps their VM and its own idle policy applies.
47
+ */
48
+ export declare function closeAllVmSessions(reg: Registry): Promise<void>;
36
49
  export declare function buildServerParts(client?: SolariClient, browserCfg?: BrowserConfig): ServerParts;
37
50
  export declare function buildServer(client?: SolariClient, browserCfg?: BrowserConfig): McpServer;
38
51
  export declare function main(): Promise<void>;
package/dist/server.js CHANGED
@@ -12,10 +12,18 @@ 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 { makeBrowserToolset } from "./browser.js";
16
- const text = (o) => ({
17
- content: [{ type: "text", text: typeof o === "string" ? o : JSON.stringify(o, null, 2) }],
18
- });
15
+ import { makeBrowserToolset, releaseAllBrowserSessions, } from "./browser.js";
16
+ // Guest output (file reads, command stdout, code results) is unbounded on the
17
+ // wire; cap it so one `cat` of a big file can't blow the MCP payload budget or
18
+ // OOM the shared hosted task.
19
+ const MAX_TOOL_TEXT = 30_000;
20
+ const text = (o) => {
21
+ const s = typeof o === "string" ? o : JSON.stringify(o, null, 2);
22
+ const capped = s.length > MAX_TOOL_TEXT
23
+ ? `${s.slice(0, MAX_TOOL_TEXT)}\n…[truncated ${s.length - MAX_TOOL_TEXT} of ${s.length} chars]`
24
+ : s;
25
+ return { content: [{ type: "text", text: capped }] };
26
+ };
19
27
  export function makeToolset(client, reg) {
20
28
  const need = (id) => {
21
29
  const e = reg.sessions.get(id);
@@ -23,10 +31,21 @@ export function makeToolset(client, reg) {
23
31
  throw new Error(`unknown sessionId: ${id}`);
24
32
  return e;
25
33
  };
34
+ // A paused session refuses the /control upgrade with 409, so resume it and
35
+ // retry rather than surfacing an opaque websocket error.
26
36
  const live = async (id) => {
27
37
  const e = need(id);
28
- if (!e.handle.connected)
38
+ if (e.handle.connected)
39
+ return e;
40
+ try {
29
41
  await e.handle.connect();
42
+ }
43
+ catch (err) {
44
+ if (typeof e.handle.resume !== "function")
45
+ throw err;
46
+ await e.handle.resume();
47
+ await e.handle.connect();
48
+ }
30
49
  return e;
31
50
  };
32
51
  const desktop = async (id) => {
@@ -45,7 +64,7 @@ export function makeToolset(client, reg) {
45
64
  ...(a.cpu ? { cpu: a.cpu } : {}),
46
65
  ...(a.memMb ? { memMb: a.memMb } : {}),
47
66
  });
48
- reg.sessions.set(sbx.sandboxId, { kind: "sandbox", handle: sbx });
67
+ reg.sessions.set(sbx.sandboxId, { kind: "sandbox", handle: sbx, commands: [] });
49
68
  return text({ sessionId: sbx.sandboxId });
50
69
  },
51
70
  },
@@ -57,7 +76,7 @@ export function makeToolset(client, reg) {
57
76
  ...(a.template ? { template: a.template } : {}),
58
77
  ...(a.resolution ? { resolution: a.resolution } : {}),
59
78
  });
60
- reg.sessions.set(d.sessionId, { kind: "desktop", handle: d });
79
+ reg.sessions.set(d.sessionId, { kind: "desktop", handle: d, commands: [] });
61
80
  return text({ sessionId: d.sessionId, streamUrl: d.streamUrl });
62
81
  },
63
82
  },
@@ -92,6 +111,13 @@ export function makeToolset(client, reg) {
92
111
  handler: async (a) => {
93
112
  const e = await live(a.sessionId);
94
113
  const h = await e.handle.commands.start("sh", { args: ["-c", a.command] });
114
+ // commands.start() arms an exit promise that REJECTS when the control
115
+ // channel closes (kill, idle-pause, gateway redeploy). Nothing else
116
+ // awaits it, so without this catch the rejection is unhandled and Node
117
+ // terminates the process — taking every other tenant's session with it
118
+ // on the hosted server.
119
+ void h.wait?.().catch(() => { });
120
+ e.commands.push(h);
95
121
  return text({ cmdId: h.cmdId });
96
122
  },
97
123
  },
@@ -104,17 +130,34 @@ export function makeToolset(client, reg) {
104
130
  },
105
131
  },
106
132
  solari_connect: {
107
- description: "Re-attach to an existing session by id (across process restarts); auto-resumes it if paused. Registers it and returns { sessionId, kind }.",
133
+ description: "Re-attach to an existing sandbox or desktop by id (e.g. across restarts); resumes it if " +
134
+ "paused. The kind is read from the gateway, so you don't need to know it. Returns { sessionId, kind, state }.",
108
135
  inputSchema: { sessionId: z.string(), kind: z.enum(["sandbox", "desktop"]).optional() },
109
136
  handler: async (a) => {
110
137
  const id = a.sessionId;
111
- const existing = reg.sessions.get(id);
112
- const kind = a.kind ?? existing?.kind ?? "sandbox";
138
+ // Trust the gateway over the caller's hint: guessing "sandbox" for a
139
+ // desktop permanently misclassifies it and every GUI tool then wrongly
140
+ // refuses the session.
141
+ let kind = a.kind ?? reg.sessions.get(id)?.kind ?? "sandbox";
142
+ let state;
143
+ try {
144
+ const view = await client.sandboxes.get(id);
145
+ if (view?.kind === "desktop" || view?.kind === "sandbox")
146
+ kind = view.kind;
147
+ state = view?.state;
148
+ }
149
+ catch {
150
+ /* fall back to the hint */
151
+ }
113
152
  const handle = kind === "desktop"
114
153
  ? await client.desktops.connect(id)
115
154
  : await client.sandboxes.connect(id);
116
- reg.sessions.set(id, { kind, handle });
117
- return text({ sessionId: id, kind });
155
+ // desktops.connect() auto-resumes; sandboxes.connect() does not.
156
+ if (kind === "sandbox" && state === "paused" && typeof handle.resume === "function") {
157
+ await handle.resume();
158
+ }
159
+ reg.sessions.set(id, { kind, handle, commands: [] });
160
+ return text({ sessionId: id, kind, state: state ?? "unknown" });
118
161
  },
119
162
  },
120
163
  solari_run_code: {
@@ -203,6 +246,27 @@ export function registerToolset(server, toolset) {
203
246
  async (args) => t.handler(args));
204
247
  }
205
248
  }
249
+ /**
250
+ * Drop our control-channel sockets for every sandbox/desktop in a registry.
251
+ *
252
+ * This is required, not cosmetic: the gateway treats a session with any open
253
+ * control/stream connection as active and never expires it, so leaving the
254
+ * channel open defeats idle-pause and the VM bills indefinitely. We close
255
+ * (not kill) so the customer keeps their VM and its own idle policy applies.
256
+ */
257
+ export async function closeAllVmSessions(reg) {
258
+ const ids = [...reg.sessions.keys()];
259
+ await Promise.all(ids.map(async (id) => {
260
+ const e = reg.sessions.get(id);
261
+ reg.sessions.delete(id);
262
+ try {
263
+ await e?.handle.close?.();
264
+ }
265
+ catch (err) {
266
+ console.error(`solari-mcp: failed to close session ${id}:`, err);
267
+ }
268
+ }));
269
+ }
206
270
  export function buildServerParts(client, browserCfg) {
207
271
  const apiKey = browserCfg?.apiKey ?? process.env.SOLARI_API_KEY ?? "";
208
272
  const c = client ??
@@ -220,13 +284,14 @@ export function buildServerParts(client, browserCfg) {
220
284
  process.env.SOLARI_BASE_URL ??
221
285
  "https://api.getsolari.com",
222
286
  };
223
- const server = new McpServer({ name: "solari-mcp", version: "0.2.0" });
287
+ const server = new McpServer({ name: "solari-mcp", version: "0.3.0" });
224
288
  const browserReg = { sessions: new Map() };
289
+ const vmReg = { sessions: new Map() };
225
290
  registerToolset(server, {
226
- ...makeToolset(c, { sessions: new Map() }),
291
+ ...makeToolset(c, vmReg),
227
292
  ...makeBrowserToolset(bCfg, browserReg),
228
293
  });
229
- return { server, browserReg };
294
+ return { server, browserReg, vmReg, browserCfg: bCfg };
230
295
  }
231
296
  export function buildServer(client, browserCfg) {
232
297
  return buildServerParts(client, browserCfg).server;
@@ -236,8 +301,34 @@ export async function main() {
236
301
  console.error("solari-mcp: SOLARI_API_KEY is required");
237
302
  process.exit(1);
238
303
  }
239
- const server = buildServer();
240
- await server.connect(new StdioServerTransport());
304
+ // A stray rejection (e.g. a background command's exit promise) must never
305
+ // take the server down mid-session.
306
+ process.on("unhandledRejection", (err) => {
307
+ console.error("solari-mcp: unhandled rejection (ignored):", err);
308
+ });
309
+ const parts = buildServerParts();
310
+ // Without this, every exit (client quit, Ctrl-C, host restart) leaves browser
311
+ // sessions held to their TTL and VM control channels open — which pins the
312
+ // VMs active so they never idle-pause. Both bill until cleaned up.
313
+ let shuttingDown = false;
314
+ const shutdown = async (sig) => {
315
+ if (shuttingDown)
316
+ return;
317
+ shuttingDown = true;
318
+ console.error(`solari-mcp: ${sig} — releasing sessions`);
319
+ await Promise.race([
320
+ Promise.allSettled([
321
+ releaseAllBrowserSessions(parts.browserCfg, parts.browserReg),
322
+ closeAllVmSessions(parts.vmReg),
323
+ ]),
324
+ new Promise((r) => setTimeout(r, 10_000)),
325
+ ]);
326
+ process.exit(0);
327
+ };
328
+ for (const sig of ["SIGTERM", "SIGINT"]) {
329
+ process.on(sig, () => void shutdown(sig));
330
+ }
331
+ await parts.server.connect(new StdioServerTransport());
241
332
  }
242
333
  // Run only when executed as the binary (not when imported by tests).
243
334
  if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {