@solarisdk/mcp 0.4.5 → 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.
@@ -169,7 +169,7 @@ export function makeAuthTools(ctx) {
169
169
  });
170
170
  }
171
171
  // ── HOT: rescue the live session ─────────────────────────────────
172
- const e = need(sessionId);
172
+ const e = await need(sessionId);
173
173
  // Try the account's password manager FIRST. If a vault is connected and
174
174
  // this site is configured, the gateway signs in on its own and no human
175
175
  // is involved at all — we never see the credential either way. Only
@@ -279,7 +279,7 @@ export function makeAuthTools(ctx) {
279
279
  profileId: z.string(),
280
280
  },
281
281
  handler: async (a) => {
282
- need(a.sessionId);
282
+ await need(a.sessionId);
283
283
  const res = await api(cfg, "POST", `/sessions/${encodeURIComponent(a.sessionId)}/save-profile`, { profileId: a.profileId });
284
284
  if (res.status !== 200)
285
285
  await apiError(res, "save profile");
@@ -351,7 +351,7 @@ export function makeAuthTools(ctx) {
351
351
  "solari_browser_login again for a fresh link.",
352
352
  });
353
353
  }
354
- const e = need(sessionId);
354
+ const e = await need(sessionId);
355
355
  while (Date.now() < deadline) {
356
356
  const q = typeof a.handoffId === "string" && a.handoffId
357
357
  ? `?handoffId=${encodeURIComponent(a.handoffId)}`
@@ -13,7 +13,7 @@ export function makeCaptureTools(ctx) {
13
13
  format: z.enum(["text", "links", "html"]).optional(),
14
14
  },
15
15
  handler: async (a) => {
16
- const page = await activePage(need(a.sessionId));
16
+ const page = await activePage(await need(a.sessionId));
17
17
  const fmt = a.format ?? "text";
18
18
  if (fmt === "links") {
19
19
  const all = await page.$$eval("a[href]", (as) => as
@@ -50,7 +50,7 @@ export function makeCaptureTools(ctx) {
50
50
  quality: z.number().min(1).max(100).optional(),
51
51
  },
52
52
  handler: async (a) => {
53
- const page = await activePage(need(a.sessionId));
53
+ const page = await activePage(await need(a.sessionId));
54
54
  const buf = await page.screenshot({
55
55
  type: "jpeg",
56
56
  quality: a.quality ?? 75,
@@ -72,7 +72,9 @@ export interface BrowserToolCtx {
72
72
  deps: BrowserDeps;
73
73
  /** deps.fetchApi, hoisted for convenience (matches the old local `api`). */
74
74
  api: typeof api;
75
- need: (id: string) => BrowserEntry;
75
+ /** Resolves an id to a live entry, REBUILDING it from the gateway on a
76
+ * cache miss (see browser.ts). Async for that reason. */
77
+ need: (id: string) => Promise<BrowserEntry>;
76
78
  /** The newest attached, non-blank page — see browser.ts for the rationale. */
77
79
  activePage: (e: BrowserEntry) => Promise<Page>;
78
80
  capText: (s: string, what: string) => string;
@@ -13,7 +13,7 @@ export function makeInteractionTools(ctx) {
13
13
  y: z.number().optional(),
14
14
  },
15
15
  handler: async (a) => {
16
- const page = await activePage(need(a.sessionId));
16
+ const page = await activePage(await need(a.sessionId));
17
17
  if (a.selector) {
18
18
  await page.click(a.selector);
19
19
  }
@@ -37,7 +37,7 @@ export function makeInteractionTools(ctx) {
37
37
  pressEnter: z.boolean().optional(),
38
38
  },
39
39
  handler: async (a) => {
40
- const page = await activePage(need(a.sessionId));
40
+ const page = await activePage(await need(a.sessionId));
41
41
  if (a.selector) {
42
42
  const sel = a.selector;
43
43
  await page.focus(sel);
@@ -60,7 +60,7 @@ export function makeInteractionTools(ctx) {
60
60
  "Chords use '+' (e.g. 'Control+a').",
61
61
  inputSchema: { sessionId: z.string(), key: z.string() },
62
62
  handler: async (a) => {
63
- const page = await activePage(need(a.sessionId));
63
+ const page = await activePage(await need(a.sessionId));
64
64
  const parts = a.key.split("+").filter(Boolean);
65
65
  const key = parts.pop();
66
66
  for (const m of parts)
@@ -79,7 +79,7 @@ export function makeInteractionTools(ctx) {
79
79
  description: "Evaluate a JavaScript expression on the page and return its JSON-serialized result.",
80
80
  inputSchema: { sessionId: z.string(), expression: z.string() },
81
81
  handler: async (a) => {
82
- const page = await activePage(need(a.sessionId));
82
+ const page = await activePage(await need(a.sessionId));
83
83
  // Pass the expression as a string: puppeteer sends it as a
84
84
  // debugger-originated Runtime.evaluate, which is exempt from the
85
85
  // page's CSP. Wrapping it in eval() inside page context is not.
@@ -8,7 +8,7 @@ export function makeNavigationTools(ctx) {
8
8
  description: "Navigate the browser session to a URL. Returns final url, title and HTTP status.",
9
9
  inputSchema: { sessionId: z.string(), url: z.string() },
10
10
  handler: async (a) => {
11
- const page = await activePage(need(a.sessionId));
11
+ const page = await activePage(await need(a.sessionId));
12
12
  let url = a.url.trim();
13
13
  if (!/^[a-z][a-z0-9+.-]*:/i.test(url))
14
14
  url = `https://${url}`;
package/dist/browser.js CHANGED
@@ -14,7 +14,7 @@
14
14
  import puppeteer from "puppeteer-core";
15
15
  import { makeAuthTools } from "./browser-tools/auth.js";
16
16
  import { makeCaptureTools } from "./browser-tools/capture.js";
17
- import { api, MAX_PAGE_TEXT, releaseBrowserSession, } from "./browser-tools/context.js";
17
+ import { api, apiError, CDP_DIAL_ATTEMPTS, MAX_PAGE_TEXT, releaseBrowserSession, } from "./browser-tools/context.js";
18
18
  import { makeInteractionTools } from "./browser-tools/interaction.js";
19
19
  import { makeNavigationTools } from "./browser-tools/navigation.js";
20
20
  import { makeSessionTools } from "./browser-tools/session.js";
@@ -31,10 +31,83 @@ const defaultDeps = {
31
31
  };
32
32
  export function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
33
33
  const apiFn = deps.fetchApi;
34
- const need = (id) => {
35
- const e = reg.sessions.get(id);
36
- if (!e)
34
+ // Rebuild a BrowserEntry for `id` by asking the gateway for the session and
35
+ // re-dialling CDP. Symmetric with the VM registry's rehydrate in server.ts,
36
+ // and necessary for the same reason: the hosted transport is stateless, so a
37
+ // request can land on a pod that never served the create call. Without this
38
+ // the browser tools failed 62.5% of the time at 2 replicas (measured on
39
+ // staging) with `unknown browser sessionId`, while the sandbox tools measured
40
+ // 0% — one registry had a rebuild path and this one did not.
41
+ //
42
+ // ⚠️ UNLIKE CREATE, A FAILURE HERE MUST NOT RELEASE THE SESSION. On create, a
43
+ // dial failure means the session leaked with an id the model never saw, so
44
+ // releasing it is correct. Here the id belongs to the caller and the session
45
+ // may be in active use from another pod — releasing on a transient dial error
46
+ // would destroy the customer's live session. So we only throw.
47
+ const rehydrateBrowser = async (id) => {
48
+ const res = await apiFn(cfg, "GET", `/sessions/${encodeURIComponent(id)}`);
49
+ if (res.status === 404) {
37
50
  throw new Error(`unknown browser sessionId: ${id} (create one with solari_browser_create)`);
51
+ }
52
+ if (!res.ok)
53
+ await apiError(res, "session lookup");
54
+ const s = (await res.json());
55
+ // ┌─ CROSS-COMPONENT CONTRACT ────────────────────────────────────────┐
56
+ // │ A browser session that can no longer be used MUST STOP REPORTING │
57
+ // │ status "active" from GET /sessions/{id}. │
58
+ // └───────────────────────────────────────────────────────────────────┘
59
+ // This file depends on that, and it is enforced NOWHERE ELSE — the
60
+ // gateway has no test asserting it and no type that carries it.
61
+ //
62
+ // Verified behaviour today (staging, 2026-09-21): after DELETE, a lookup
63
+ // returns 200 { status: "released" } with NO cdpEndpoint. So the endpoint
64
+ // check below would already refuse it — but only incidentally. Gateway-side
65
+ // expiry of idle-but-connected sessions is being designed right now; if it
66
+ // marks a session dead while still echoing its endpoint, this function
67
+ // hands back a handle to a corpse and the failure surfaces as a confusing
68
+ // raw CDP error at the call site. It will look like a rehydrate bug and it
69
+ // will not be one.
70
+ //
71
+ // So assert the state we actually care about, rather than relying on a side
72
+ // effect of the current response shape.
73
+ if (s.status && s.status !== "active") {
74
+ throw new Error(`browser session ${id} is ${s.status}; create a new one with solari_browser_create`);
75
+ }
76
+ const cdp = s.cdpEndpoint ?? s.wsEndpoint?.replace("/ws/", "/cdp/");
77
+ if (!cdp)
78
+ throw new Error(`browser session ${id} has no cdpEndpoint to reconnect to`);
79
+ let browser;
80
+ let lastErr;
81
+ for (let i = 0; i < CDP_DIAL_ATTEMPTS; i++) {
82
+ try {
83
+ browser = await deps.connect(cdp);
84
+ break;
85
+ }
86
+ catch (err) {
87
+ lastErr = err;
88
+ await new Promise((r) => setTimeout(r, 500 * (i + 1)));
89
+ }
90
+ }
91
+ if (!browser) {
92
+ throw new Error(`could not reattach to browser session ${id}: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`);
93
+ }
94
+ const pages = await browser.pages();
95
+ const page = pages.find((p) => !p.isClosed()) ?? (await browser.newPage());
96
+ const entry = {
97
+ sessionId: id,
98
+ browser,
99
+ page,
100
+ expiresAt: s.expiresAt ?? "",
101
+ // Not recoverable from the gateway; false is the safe default — it only
102
+ // gates whether tools mention a recording, never whether one exists.
103
+ recording: false,
104
+ cdpEndpoint: cdp,
105
+ };
106
+ reg.sessions.set(id, entry);
107
+ return entry;
108
+ };
109
+ const need = async (id) => {
110
+ const e = reg.sessions.get(id) ?? (await rehydrateBrowser(id));
38
111
  // The signed CDP URL is not re-dialable after the session expires, so say
39
112
  // so plainly instead of surfacing a raw "Target closed".
40
113
  if (e.expiresAt && Date.parse(e.expiresAt) <= Date.now()) {
package/dist/http.js CHANGED
@@ -1,20 +1,66 @@
1
1
  #!/usr/bin/env node
2
2
  // solari-mcp over Streamable HTTP — the hosted, multi-tenant variant.
3
3
  //
4
- // One deployment serves every customer: each MCP session is bound to the
5
- // slr_live_ API key presented in the Authorization header at initialize time,
6
- // and gets its own McpServer + tool registries. Subsequent requests are routed
7
- // by the mcp-session-id header and must present the same key (sessions are a
8
- // tenant boundary, not just a transport detail).
4
+ // One deployment serves every customer. The transport is STATELESS: there is no
5
+ // mcp-session-id and no per-conversation server state, so ANY POD CAN SERVE ANY
6
+ // REQUEST and the deployment scales horizontally.
7
+ //
8
+ // WHY (2026-09-21). This used to key a live McpServer + transport by the
9
+ // mcp-session-id header into a process-local Map. That made the connector
10
+ // single-replica by construction: measured on staging, at 2 replicas 23 of 50
11
+ // follow-up calls returned 404 "unknown mcp-session-id" (46%), and the MCP
12
+ // client does not recover from a 404 — its _sessionId is cleared only by an
13
+ // explicit terminateSession(), so the conversation dies rather than reconnects.
14
+ //
15
+ // Affinity cannot fix it: the MCP client SDK never stores or sends cookies, so
16
+ // GENERATED_COOKIE affinity is inert, and GCPBackendPolicy cannot express
17
+ // HEADER_FIELD affinity on mcp-session-id (no consistentHash/httpHeaderName).
18
+ // Rebuilding a session in place under the client's id is also impossible — a
19
+ // fresh transport given a non-initialize request answers
20
+ // 400 "Bad Request: Server not initialized".
21
+ //
22
+ // What makes stateless safe here: the GATEWAY is the source of truth for every
23
+ // sandbox/desktop, the client already carries every id, and this server emits
24
+ // NO server-initiated notifications (asserted by a test), so nothing depends on
25
+ // a long-lived SSE stream. Tool state is therefore a cache, rebuilt on demand
26
+ // (server.ts `need()`).
27
+ //
28
+ // PER-TENANT CACHE + THE BRIDGE SWEEP. We still keep one pair of tool
29
+ // registries per API key, for two reasons: warm handles (otherwise every call
30
+ // re-attaches from the gateway), and — more importantly — SOMETHING HAS TO
31
+ // RELEASE ABANDONED RESOURCES. Models rarely call the close tools, so
32
+ // abandonment is the normal end of a conversation, and a browser session holds
33
+ // a concurrency-limited pool slot until min(plan maxSessionMinutes, the pool's
34
+ // SESSION_TTL_MS default of 24h) — 24h on professional and enterprise, i.e. up
35
+ // to 48x this sweep's interval. The 210s ORPHAN_GRACE_MS does not help: it only
36
+ // reaps sessions that never connected, and an MCP browser session does connect.
37
+ //
38
+ // 🪤 LIMITATION, BY DESIGN: a pod sweeps only the tenants IT has served. If a
39
+ // pod dies, its tenants' browser sessions leak until that 24h backstop. This is
40
+ // a BRIDGE. The durable fix is for the browser gateway to expire
41
+ // idle-but-connected sessions itself, which fixes every client and not just
42
+ // MCP — see the follow-up issue; do not let this sweep become the reason that
43
+ // never happens.
9
44
  //
10
45
  // PORT listen port (default 8080)
11
46
  // SOLARI_BASE_URL sandbox/desktop gateway (default https://api.getsolari.com)
12
47
  // SOLARI_BROWSER_URL browser gateway (default = SOLARI_BASE_URL)
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)
48
+ // SESSION_IDLE_MS release a tenant's resources after this much idleness
49
+ // (default 30 min) the bridge sweep above
50
+ // MAX_SESSIONS cap on live tenants (distinct API keys) on this pod
51
+ // TENANT_IDLE_SWEEP set to "single-replica-only" to enable the destructive
52
+ // idle sweep. Valid ONLY at replicas: 1 — see the sweep.
53
+ // MAX_SESSIONS_PER_KEY cap on live VM+browser handles per key on this pod.
54
+ // REINTERPRETED 2026-09-21: it used to cap concurrent
55
+ // MCP conversations, which no longer exist. Both caps
56
+ // are PER POD and deliberately not divided by replica
57
+ // count — dividing breaks silently the moment someone
58
+ // scales. Safe because neither is authoritative: the
59
+ // gateway enforces per-org concurrency in Postgres and
60
+ // the console gates spend at admission. This is
61
+ // defence-in-depth against a runaway client.
16
62
  import { createServer } from "node:http";
17
- import { createHash, randomUUID } from "node:crypto";
63
+ import { createHash } from "node:crypto";
18
64
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
19
65
  import { buildServerParts, closeAllVmSessions } from "./server.js";
20
66
  import { releaseAllBrowserSessions } from "./browser.js";
@@ -25,9 +71,16 @@ const MAX_PER_KEY = Number(process.env.MAX_SESSIONS_PER_KEY ?? 20);
25
71
  const MAX_BODY_BYTES = 4 * 1024 * 1024;
26
72
  const DESKTOP_URL = process.env.SOLARI_BASE_URL ?? "https://api.getsolari.com";
27
73
  const BROWSER_URL = process.env.SOLARI_BROWSER_URL ?? DESKTOP_URL;
28
- const sessions = new Map();
74
+ // Keyed by API-key hash, NOT by mcp-session-id: one tenant per customer key on
75
+ // this pod, holding the warm handle cache the sweep is responsible for.
76
+ const tenants = new Map();
29
77
  const hash = (key) => createHash("sha256").update(key).digest("hex");
30
- const countForKey = (h) => [...sessions.values()].filter((s) => s.keyHash === h).length;
78
+ // Live cloud handles this pod holds for a key — the thing worth bounding now
79
+ // that MCP conversations are not a countable resource.
80
+ const handlesForKey = (h) => {
81
+ const t = tenants.get(h);
82
+ return t ? t.vmReg.sessions.size + t.browserReg.sessions.size : 0;
83
+ };
31
84
  // Cheap upfront key validation (the gateways are the real authority; this just
32
85
  // gives a clean 401 at connect time instead of failures on the first tool
33
86
  // call). Results are cached both ways and coalesced per key so a reconnect
@@ -105,33 +158,55 @@ async function readBody(req) {
105
158
  return undefined;
106
159
  return JSON.parse(raw);
107
160
  }
108
- async function evict(id, why) {
109
- const s = sessions.get(id);
110
- if (!s)
161
+ async function releaseTenant(keyHash, why) {
162
+ const t = tenants.get(keyHash);
163
+ if (!t)
111
164
  return;
112
- sessions.delete(id);
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).
165
+ tenants.delete(keyHash);
166
+ // Actually RELEASE the customer's cloud resources. A browser session holds a
167
+ // concurrency-limited pool slot, and an open VM control channel pins the VM
168
+ // "active" so it never idle-pauses — both bill until we let go, and
169
+ // abandoning the conversation is the normal end-of-conversation path (models
170
+ // rarely call the close tools).
118
171
  await Promise.allSettled([
119
- releaseAllBrowserSessions(s.browserCfg, s.browserReg),
120
- closeAllVmSessions(s.vmReg),
172
+ releaseAllBrowserSessions(t.browserCfg, t.browserReg),
173
+ closeAllVmSessions(t.vmReg),
121
174
  ]);
122
- try {
123
- await s.transport.close();
124
- }
125
- catch {
126
- /* already closed */
127
- }
128
- console.error(`mcp session ${id} evicted (${why})`);
175
+ console.error(`mcp tenant ${keyHash.slice(0, 8)} released (${why})`);
176
+ }
177
+ // THE BRIDGE SWEEP (see the header). Without it nothing frees an abandoned
178
+ // browser session before the pool's 24h backstop.
179
+ //
180
+ // 🚨 DESTRUCTIVE, AND ONLY SOUND AT ONE REPLICA — so it is OFF unless the
181
+ // deployment says otherwise. The idle clock is per-pod but DELETE /sessions/{id}
182
+ // is global: at 2 replicas, a pod that stops seeing a key's traffic for
183
+ // IDLE_MS will tear down every browser session that key holds, including ones
184
+ // another pod is actively driving. The pod has no signal for "a peer is using
185
+ // this" (the gateway reports status:active but no lastActivityAt), and it cannot
186
+ // discover its peer count either — the default KSA gets 403 on its own Service
187
+ // endpoints, and a guard that reads 403 as "0 peers, I am alone" would automate
188
+ // the hazard instead of preventing it.
189
+ //
190
+ // So the switch is explicit configuration, set in the SAME overlay hunk as
191
+ // `replicas:` and under the same comment. That does not prevent the mistake, but
192
+ // it puts the two coupled settings three lines apart in one diff rather than in
193
+ // two different systems. Default OFF means a deployment that forgets the flag
194
+ // leaks to the 24h backstop — the safe direction.
195
+ //
196
+ // RELEASE CONDITION for removing all of this: gateway-side expiry of
197
+ // idle-but-connected browser sessions, which fixes every client, not just MCP.
198
+ const SWEEP_ENABLED = process.env.TENANT_IDLE_SWEEP === "single-replica-only";
199
+ if (!SWEEP_ENABLED) {
200
+ console.error("solari-mcp: tenant idle sweep DISABLED (TENANT_IDLE_SWEEP != single-replica-only). " +
201
+ "Abandoned browser sessions will hold a pool slot until the gateway's 24h backstop.");
129
202
  }
130
203
  const idleSweep = setInterval(() => {
204
+ if (!SWEEP_ENABLED)
205
+ return;
131
206
  const now = Date.now();
132
- for (const [id, s] of sessions)
133
- if (now - s.lastSeen > IDLE_MS)
134
- void evict(id, "idle");
207
+ for (const [h, t] of tenants)
208
+ if (now - t.lastSeen > IDLE_MS)
209
+ void releaseTenant(h, "idle");
135
210
  }, 60_000);
136
211
  idleSweep.unref();
137
212
  async function handleMcp(req, res) {
@@ -140,25 +215,11 @@ async function handleMcp(req, res) {
140
215
  json(res, 401, { error: "Authorization: Bearer slr_live_… required" });
141
216
  return;
142
217
  }
143
- const sid = req.headers["mcp-session-id"];
144
- if (sid) {
145
- const s = sessions.get(sid);
146
- if (!s) {
147
- json(res, 404, { error: "unknown mcp-session-id (session may have been evicted)" });
148
- return;
149
- }
150
- if (s.keyHash !== hash(key)) {
151
- json(res, 403, { error: "mcp-session-id belongs to a different API key" });
152
- return;
153
- }
154
- s.lastSeen = Date.now();
155
- const body = req.method === "POST" ? await readBody(req) : undefined;
156
- await s.transport.handleRequest(req, res, body);
157
- return;
158
- }
159
- // No session id → must be a POST initialize.
218
+ // Stateless: every request is self-contained, so every request presents the
219
+ // key and every request is authenticated. There is no "first request" to
220
+ // privilege and no session to hijack — the key IS the tenant boundary.
160
221
  if (req.method !== "POST") {
161
- json(res, 400, { error: "mcp-session-id header required" });
222
+ json(res, 405, { error: "POST /mcp only (this transport is stateless)" });
162
223
  return;
163
224
  }
164
225
  if (!(await validateKey(key))) {
@@ -166,38 +227,52 @@ async function handleMcp(req, res) {
166
227
  return;
167
228
  }
168
229
  const keyHash = hash(key);
169
- if (sessions.size >= MAX_SESSIONS) {
170
- json(res, 503, { error: "server at session capacity, retry shortly" });
171
- return;
230
+ let tenant = tenants.get(keyHash);
231
+ if (!tenant) {
232
+ if (tenants.size >= MAX_SESSIONS) {
233
+ json(res, 503, { error: "server at capacity, retry shortly" });
234
+ return;
235
+ }
236
+ const parts = buildServerParts(undefined, { apiKey: key, baseUrl: BROWSER_URL });
237
+ tenant = {
238
+ browserReg: parts.browserReg,
239
+ browserCfg: parts.browserCfg,
240
+ vmReg: parts.vmReg,
241
+ lastSeen: Date.now(),
242
+ };
243
+ tenants.set(keyHash, tenant);
172
244
  }
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})` });
245
+ if (handlesForKey(keyHash) > MAX_PER_KEY) {
246
+ json(res, 429, {
247
+ error: `too many concurrent Solari sessions for this API key (max ${MAX_PER_KEY})`,
248
+ });
175
249
  return;
176
250
  }
251
+ tenant.lastSeen = Date.now();
177
252
  const body = await readBody(req);
178
- const parts = buildServerParts(undefined, { apiKey: key, baseUrl: BROWSER_URL });
179
- const transport = new StreamableHTTPServerTransport({
180
- sessionIdGenerator: randomUUID,
181
- onsessioninitialized: (id) => {
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)})`);
191
- },
192
- onsessionclosed: (id) => void evict(id, "client closed"),
193
- });
253
+ // A fresh McpServer + stateless transport per request, reusing this tenant's
254
+ // registries so warm handles survive and the sweep has something to release.
255
+ const parts = buildServerParts(undefined, { apiKey: key, baseUrl: BROWSER_URL }, { browserReg: tenant.browserReg, vmReg: tenant.vmReg });
256
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
194
257
  await parts.server.connect(transport);
195
- await transport.handleRequest(req, res, body);
258
+ try {
259
+ await transport.handleRequest(req, res, body);
260
+ }
261
+ finally {
262
+ // Close the per-request transport only. The tenant's registries — and the
263
+ // customer's live VMs and browser sessions — deliberately outlive it.
264
+ try {
265
+ await transport.close();
266
+ }
267
+ catch {
268
+ /* already closed */
269
+ }
270
+ }
196
271
  }
197
272
  const httpServer = createServer((req, res) => {
198
273
  const url = req.url?.split("?")[0];
199
274
  if (url === "/health" || url === "/healthz") {
200
- json(res, 200, { ok: true, sessions: sessions.size });
275
+ json(res, 200, { ok: true, tenants: tenants.size, stateless: true });
201
276
  return;
202
277
  }
203
278
  if (url === "/mcp") {
@@ -223,13 +298,13 @@ async function shutdown(sig) {
223
298
  if (shuttingDown)
224
299
  return;
225
300
  shuttingDown = true;
226
- console.error(`solari-mcp: ${sig} — draining ${sessions.size} session(s)`);
301
+ console.error(`solari-mcp: ${sig} — draining ${tenants.size} tenant(s)`);
227
302
  httpServer.close();
228
303
  clearInterval(idleSweep);
229
304
  // Release every tenant's cloud resources; without this an ECS rollout leaks
230
305
  // one pool slot + one pinned VM per live conversation.
231
306
  await Promise.race([
232
- Promise.allSettled([...sessions.keys()].map((id) => evict(id, sig))),
307
+ Promise.allSettled([...tenants.keys()].map((h) => releaseTenant(h, sig))),
233
308
  new Promise((r) => setTimeout(r, 20_000)),
234
309
  ]);
235
310
  process.exit(0);
package/dist/server.d.ts CHANGED
@@ -46,7 +46,10 @@ export interface ServerParts {
46
46
  * (not kill) so the customer keeps their VM and its own idle policy applies.
47
47
  */
48
48
  export declare function closeAllVmSessions(reg: Registry): Promise<void>;
49
- export declare function buildServerParts(client?: SolariClient, browserCfg?: BrowserConfig): ServerParts;
49
+ export declare function buildServerParts(client?: SolariClient, browserCfg?: BrowserConfig, regs?: {
50
+ browserReg?: BrowserRegistry;
51
+ vmReg?: Registry;
52
+ }): ServerParts;
50
53
  export declare function buildServer(client?: SolariClient, browserCfg?: BrowserConfig): McpServer;
51
54
  export declare function main(): Promise<void>;
52
55
  export {};