@solarisdk/mcp 0.2.1 → 0.3.0

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,41 +11,76 @@
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();
27
- async function validateKey(key) {
28
- const h = hash(key);
29
- const until = keyOkUntil.get(h);
30
- if (until && until > Date.now())
31
- return true;
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) {
32
38
  try {
33
- const res = await fetch(`${BROWSER_URL}/profiles`, {
39
+ const res = await fetch(url, {
34
40
  headers: { authorization: `Bearer ${key}` },
35
- signal: AbortSignal.timeout(10_000),
41
+ signal: AbortSignal.timeout(5_000),
36
42
  });
37
- if (res.ok) {
38
- keyOkUntil.set(h, Date.now() + 60_000);
39
- return true;
40
- }
41
- // Gateway auth can negative-cache a freshly minted key for a few seconds;
42
- // treat only a definitive 401/403 as invalid.
43
- return res.status !== 401 && res.status !== 403;
43
+ if (res.ok)
44
+ return "ok";
45
+ if (res.status === 401 || res.status === 403)
46
+ return "reject";
47
+ return "unknown";
44
48
  }
45
49
  catch {
46
- return true; // don't lock customers out on our own transient errors
50
+ return "unknown";
47
51
  }
48
52
  }
53
+ async function validateKey(key) {
54
+ const h = hash(key);
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;
71
+ }
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;
76
+ }
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;
83
+ }
49
84
  function bearer(req) {
50
85
  const m = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? "");
51
86
  return m?.[1];
@@ -54,9 +89,17 @@ function json(res, status, body) {
54
89
  res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body));
55
90
  }
56
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 });
57
95
  const chunks = [];
58
- 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 });
59
101
  chunks.push(c);
102
+ }
60
103
  const raw = Buffer.concat(chunks).toString("utf8");
61
104
  if (!raw)
62
105
  return undefined;
@@ -67,16 +110,15 @@ async function evict(id, why) {
67
110
  if (!s)
68
111
  return;
69
112
  sessions.delete(id);
70
- // Disconnect any live puppeteer handles (the cloud sessions themselves are
71
- // governed by their own idle/pause TTLs we only drop our sockets).
72
- for (const e of s.browserReg.sessions.values()) {
73
- try {
74
- await e.browser.disconnect();
75
- }
76
- catch {
77
- /* already gone */
78
- }
79
- }
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
+ ]);
80
122
  try {
81
123
  await s.transport.close();
82
124
  }
@@ -85,12 +127,13 @@ async function evict(id, why) {
85
127
  }
86
128
  console.error(`mcp session ${id} evicted (${why})`);
87
129
  }
88
- setInterval(() => {
130
+ const idleSweep = setInterval(() => {
89
131
  const now = Date.now();
90
132
  for (const [id, s] of sessions)
91
133
  if (now - s.lastSeen > IDLE_MS)
92
134
  void evict(id, "idle");
93
- }, 60_000).unref();
135
+ }, 60_000);
136
+ idleSweep.unref();
94
137
  async function handleMcp(req, res) {
95
138
  const key = bearer(req);
96
139
  if (!key || !key.startsWith("slr_live_")) {
@@ -122,20 +165,33 @@ async function handleMcp(req, res) {
122
165
  json(res, 401, { error: "invalid Solari API key" });
123
166
  return;
124
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
+ }
125
177
  const body = await readBody(req);
126
- const { server, browserReg } = buildServerParts(undefined, {
127
- apiKey: key,
128
- baseUrl: BROWSER_URL,
129
- });
178
+ const parts = buildServerParts(undefined, { apiKey: key, baseUrl: BROWSER_URL });
130
179
  const transport = new StreamableHTTPServerTransport({
131
180
  sessionIdGenerator: randomUUID,
132
181
  onsessioninitialized: (id) => {
133
- sessions.set(id, { transport, browserReg, keyHash: hash(key), lastSeen: Date.now() });
134
- 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)})`);
135
191
  },
136
192
  onsessionclosed: (id) => void evict(id, "client closed"),
137
193
  });
138
- await server.connect(transport);
194
+ await parts.server.connect(transport);
139
195
  await transport.handleRequest(req, res, body);
140
196
  }
141
197
  const httpServer = createServer((req, res) => {
@@ -146,9 +202,11 @@ const httpServer = createServer((req, res) => {
146
202
  }
147
203
  if (url === "/mcp") {
148
204
  handleMcp(req, res).catch((err) => {
149
- console.error("mcp request failed:", err);
205
+ const status = err.status ?? 500;
206
+ if (status !== 413)
207
+ console.error("mcp request failed:", err);
150
208
  if (!res.headersSent)
151
- json(res, 500, { error: "internal error" });
209
+ json(res, status, { error: status === 413 ? "body too large" : "internal error" });
152
210
  else
153
211
  res.end();
154
212
  });
@@ -156,7 +214,29 @@ const httpServer = createServer((req, res) => {
156
214
  }
157
215
  json(res, 404, { error: "not found (use /mcp)" });
158
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
+ }
159
240
  httpServer.listen(PORT, () => {
160
- console.error(`solari-mcp http listening on :${PORT} (browser gw ${BROWSER_URL}, ` +
161
- `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})`);
162
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 ??
@@ -212,19 +276,22 @@ export function buildServerParts(client, browserCfg) {
212
276
  });
213
277
  // The cloud-browser gateway is a separate service from the sandbox/desktop
214
278
  // gateway (same domain in prod, different domains on staging).
279
+ // Prod's browser + desktop gateways have separate key stores today; allow a
280
+ // browser-specific key (falls back to the shared one).
215
281
  const bCfg = browserCfg ?? {
216
- apiKey,
282
+ apiKey: process.env.SOLARI_BROWSER_API_KEY ?? apiKey,
217
283
  baseUrl: process.env.SOLARI_BROWSER_URL ??
218
284
  process.env.SOLARI_BASE_URL ??
219
285
  "https://api.getsolari.com",
220
286
  };
221
- const server = new McpServer({ name: "solari-mcp", version: "0.2.0" });
287
+ const server = new McpServer({ name: "solari-mcp", version: "0.3.0" });
222
288
  const browserReg = { sessions: new Map() };
289
+ const vmReg = { sessions: new Map() };
223
290
  registerToolset(server, {
224
- ...makeToolset(c, { sessions: new Map() }),
291
+ ...makeToolset(c, vmReg),
225
292
  ...makeBrowserToolset(bCfg, browserReg),
226
293
  });
227
- return { server, browserReg };
294
+ return { server, browserReg, vmReg, browserCfg: bCfg };
228
295
  }
229
296
  export function buildServer(client, browserCfg) {
230
297
  return buildServerParts(client, browserCfg).server;
@@ -234,8 +301,34 @@ export async function main() {
234
301
  console.error("solari-mcp: SOLARI_API_KEY is required");
235
302
  process.exit(1);
236
303
  }
237
- const server = buildServer();
238
- 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());
239
332
  }
240
333
  // Run only when executed as the binary (not when imported by tests).
241
334
  if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {