@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/README.md +1 -0
- package/dist/browser.d.ts +4 -0
- package/dist/browser.js +216 -76
- package/dist/http.js +123 -43
- package/dist/server.d.ts +13 -0
- package/dist/server.js +111 -18
- package/dist/solari-mcp-http.bundle.cjs +114735 -0
- package/dist/solari-mcp.bundle.cjs +113219 -0
- package/package.json +6 -5
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
|
|
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).
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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(
|
|
39
|
+
const res = await fetch(url, {
|
|
34
40
|
headers: { authorization: `Bearer ${key}` },
|
|
35
|
-
signal: AbortSignal.timeout(
|
|
41
|
+
signal: AbortSignal.timeout(5_000),
|
|
36
42
|
});
|
|
37
|
-
if (res.ok)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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)
|
|
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
|
|
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, {
|
|
134
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
17
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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.
|
|
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,
|
|
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
|
-
|
|
238
|
-
|
|
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]}`) {
|