@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.
- package/dist/browser-tools/auth.js +3 -3
- package/dist/browser-tools/capture.js +2 -2
- package/dist/browser-tools/context.d.ts +3 -1
- package/dist/browser-tools/interaction.js +4 -4
- package/dist/browser-tools/navigation.js +1 -1
- package/dist/browser.js +77 -4
- package/dist/http.js +150 -75
- package/dist/server.d.ts +4 -1
- package/dist/server.js +144 -11
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/solari-mcp-http.bundle.cjs +0 -115191
- package/dist/solari-mcp.bundle.cjs +0 -113675
|
@@ -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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
5
|
-
//
|
|
6
|
-
// and
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
109
|
-
const
|
|
110
|
-
if (!
|
|
161
|
+
async function releaseTenant(keyHash, why) {
|
|
162
|
+
const t = tenants.get(keyHash);
|
|
163
|
+
if (!t)
|
|
111
164
|
return;
|
|
112
|
-
|
|
113
|
-
// Actually RELEASE the customer's cloud resources.
|
|
114
|
-
// concurrency-limited pool slot
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
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(
|
|
120
|
-
closeAllVmSessions(
|
|
172
|
+
releaseAllBrowserSessions(t.browserCfg, t.browserReg),
|
|
173
|
+
closeAllVmSessions(t.vmReg),
|
|
121
174
|
]);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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 [
|
|
133
|
-
if (now -
|
|
134
|
-
void
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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,
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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 (
|
|
174
|
-
json(res, 429, {
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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,
|
|
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 ${
|
|
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([...
|
|
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
|
|
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 {};
|