@solarisdk/mcp 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -7
- package/dist/browser.d.ts +4 -0
- package/dist/browser.js +276 -82
- package/dist/http.js +125 -58
- package/dist/server.d.ts +13 -0
- package/dist/server.js +108 -17
- package/dist/solari-mcp-http.bundle.cjs +114750 -0
- package/dist/solari-mcp.bundle.cjs +113234 -0
- package/package.json +7 -6
package/dist/http.js
CHANGED
|
@@ -11,53 +11,75 @@
|
|
|
11
11
|
// SOLARI_BASE_URL sandbox/desktop gateway (default https://api.getsolari.com)
|
|
12
12
|
// SOLARI_BROWSER_URL browser gateway (default = SOLARI_BASE_URL)
|
|
13
13
|
// SESSION_IDLE_MS evict MCP sessions idle longer than this (default 30 min)
|
|
14
|
+
// MAX_SESSIONS global cap on live MCP sessions (default 500)
|
|
15
|
+
// MAX_SESSIONS_PER_KEY per-API-key cap (default 20)
|
|
14
16
|
import { createServer } from "node:http";
|
|
15
17
|
import { createHash, randomUUID } from "node:crypto";
|
|
16
18
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
17
|
-
import { buildServerParts } from "./server.js";
|
|
19
|
+
import { buildServerParts, closeAllVmSessions } from "./server.js";
|
|
20
|
+
import { releaseAllBrowserSessions } from "./browser.js";
|
|
18
21
|
const PORT = Number(process.env.PORT ?? 8080);
|
|
19
22
|
const IDLE_MS = Number(process.env.SESSION_IDLE_MS ?? 30 * 60 * 1000);
|
|
20
|
-
const
|
|
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
|
-
|
|
33
|
+
// call). Results are cached both ways and coalesced per key so a reconnect
|
|
34
|
+
// storm can't amplify into the prod auth path.
|
|
35
|
+
const keyCache = new Map();
|
|
36
|
+
const inflight = new Map();
|
|
37
|
+
async function probe(url, key) {
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetch(url, {
|
|
40
|
+
headers: { authorization: `Bearer ${key}` },
|
|
41
|
+
signal: AbortSignal.timeout(5_000),
|
|
42
|
+
});
|
|
43
|
+
if (res.ok)
|
|
44
|
+
return "ok";
|
|
45
|
+
if (res.status === 401 || res.status === 403)
|
|
46
|
+
return "reject";
|
|
47
|
+
return "unknown";
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return "unknown";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
27
53
|
async function validateKey(key) {
|
|
28
54
|
const h = hash(key);
|
|
29
|
-
const
|
|
30
|
-
if (
|
|
31
|
-
return
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
});
|
|
46
|
-
if (res.ok) {
|
|
47
|
-
keyOkUntil.set(h, Date.now() + 60_000);
|
|
48
|
-
return true;
|
|
49
|
-
}
|
|
50
|
-
if (res.status === 401 || res.status === 403)
|
|
51
|
-
sawDefinitiveReject = true;
|
|
52
|
-
// Other statuses: inconclusive (gateway hiccup) — keep probing.
|
|
55
|
+
const hit = keyCache.get(h);
|
|
56
|
+
if (hit && hit.until > Date.now())
|
|
57
|
+
return hit.ok;
|
|
58
|
+
const existing = inflight.get(h);
|
|
59
|
+
if (existing)
|
|
60
|
+
return existing;
|
|
61
|
+
const p = (async () => {
|
|
62
|
+
// Prod's browser and desktop gateways have separate key stores — a key is
|
|
63
|
+
// acceptable if EITHER accepts it. Probe both concurrently.
|
|
64
|
+
const [b, d] = await Promise.all([
|
|
65
|
+
probe(`${BROWSER_URL}/profiles`, key),
|
|
66
|
+
probe(`${DESKTOP_URL}/sandboxes?limit=1`, key),
|
|
67
|
+
]);
|
|
68
|
+
if (b === "ok" || d === "ok") {
|
|
69
|
+
keyCache.set(h, { ok: true, until: Date.now() + 60_000 });
|
|
70
|
+
return true;
|
|
53
71
|
}
|
|
54
|
-
|
|
55
|
-
|
|
72
|
+
if (b === "reject" || d === "reject") {
|
|
73
|
+
// Definitive no from a gateway that answered.
|
|
74
|
+
keyCache.set(h, { ok: false, until: Date.now() + 30_000 });
|
|
75
|
+
return false;
|
|
56
76
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
77
|
+
// Everything inconclusive (our blip, not the customer's fault): allow, but
|
|
78
|
+
// don't cache, so we re-check on the next connect.
|
|
79
|
+
return true;
|
|
80
|
+
})().finally(() => inflight.delete(h));
|
|
81
|
+
inflight.set(h, p);
|
|
82
|
+
return p;
|
|
61
83
|
}
|
|
62
84
|
function bearer(req) {
|
|
63
85
|
const m = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? "");
|
|
@@ -67,9 +89,17 @@ function json(res, status, body) {
|
|
|
67
89
|
res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body));
|
|
68
90
|
}
|
|
69
91
|
async function readBody(req) {
|
|
92
|
+
const declared = Number(req.headers["content-length"] ?? 0);
|
|
93
|
+
if (declared > MAX_BODY_BYTES)
|
|
94
|
+
throw Object.assign(new Error("body too large"), { status: 413 });
|
|
70
95
|
const chunks = [];
|
|
71
|
-
|
|
96
|
+
let size = 0;
|
|
97
|
+
for await (const c of req) {
|
|
98
|
+
size += c.length;
|
|
99
|
+
if (size > MAX_BODY_BYTES)
|
|
100
|
+
throw Object.assign(new Error("body too large"), { status: 413 });
|
|
72
101
|
chunks.push(c);
|
|
102
|
+
}
|
|
73
103
|
const raw = Buffer.concat(chunks).toString("utf8");
|
|
74
104
|
if (!raw)
|
|
75
105
|
return undefined;
|
|
@@ -80,16 +110,15 @@ async function evict(id, why) {
|
|
|
80
110
|
if (!s)
|
|
81
111
|
return;
|
|
82
112
|
sessions.delete(id);
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
113
|
+
// Actually RELEASE the customer's cloud resources. Browser sessions hold a
|
|
114
|
+
// concurrency-limited pool slot until their hard TTL, and an open VM control
|
|
115
|
+
// channel pins the VM "active" so it never idle-pauses — both bill until we
|
|
116
|
+
// let go, and abandoning the MCP session is the normal end-of-conversation
|
|
117
|
+
// path (models rarely call the close tools).
|
|
118
|
+
await Promise.allSettled([
|
|
119
|
+
releaseAllBrowserSessions(s.browserCfg, s.browserReg),
|
|
120
|
+
closeAllVmSessions(s.vmReg),
|
|
121
|
+
]);
|
|
93
122
|
try {
|
|
94
123
|
await s.transport.close();
|
|
95
124
|
}
|
|
@@ -98,12 +127,13 @@ async function evict(id, why) {
|
|
|
98
127
|
}
|
|
99
128
|
console.error(`mcp session ${id} evicted (${why})`);
|
|
100
129
|
}
|
|
101
|
-
setInterval(() => {
|
|
130
|
+
const idleSweep = setInterval(() => {
|
|
102
131
|
const now = Date.now();
|
|
103
132
|
for (const [id, s] of sessions)
|
|
104
133
|
if (now - s.lastSeen > IDLE_MS)
|
|
105
134
|
void evict(id, "idle");
|
|
106
|
-
}, 60_000)
|
|
135
|
+
}, 60_000);
|
|
136
|
+
idleSweep.unref();
|
|
107
137
|
async function handleMcp(req, res) {
|
|
108
138
|
const key = bearer(req);
|
|
109
139
|
if (!key || !key.startsWith("slr_live_")) {
|
|
@@ -135,20 +165,33 @@ async function handleMcp(req, res) {
|
|
|
135
165
|
json(res, 401, { error: "invalid Solari API key" });
|
|
136
166
|
return;
|
|
137
167
|
}
|
|
168
|
+
const keyHash = hash(key);
|
|
169
|
+
if (sessions.size >= MAX_SESSIONS) {
|
|
170
|
+
json(res, 503, { error: "server at session capacity, retry shortly" });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (countForKey(keyHash) >= MAX_PER_KEY) {
|
|
174
|
+
json(res, 429, { error: `too many concurrent MCP sessions for this API key (max ${MAX_PER_KEY})` });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
138
177
|
const body = await readBody(req);
|
|
139
|
-
const
|
|
140
|
-
apiKey: key,
|
|
141
|
-
baseUrl: BROWSER_URL,
|
|
142
|
-
});
|
|
178
|
+
const parts = buildServerParts(undefined, { apiKey: key, baseUrl: BROWSER_URL });
|
|
143
179
|
const transport = new StreamableHTTPServerTransport({
|
|
144
180
|
sessionIdGenerator: randomUUID,
|
|
145
181
|
onsessioninitialized: (id) => {
|
|
146
|
-
sessions.set(id, {
|
|
147
|
-
|
|
182
|
+
sessions.set(id, {
|
|
183
|
+
transport,
|
|
184
|
+
browserReg: parts.browserReg,
|
|
185
|
+
browserCfg: parts.browserCfg,
|
|
186
|
+
vmReg: parts.vmReg,
|
|
187
|
+
keyHash,
|
|
188
|
+
lastSeen: Date.now(),
|
|
189
|
+
});
|
|
190
|
+
console.error(`mcp session ${id} started (key ${keyHash.slice(0, 8)})`);
|
|
148
191
|
},
|
|
149
192
|
onsessionclosed: (id) => void evict(id, "client closed"),
|
|
150
193
|
});
|
|
151
|
-
await server.connect(transport);
|
|
194
|
+
await parts.server.connect(transport);
|
|
152
195
|
await transport.handleRequest(req, res, body);
|
|
153
196
|
}
|
|
154
197
|
const httpServer = createServer((req, res) => {
|
|
@@ -159,9 +202,11 @@ const httpServer = createServer((req, res) => {
|
|
|
159
202
|
}
|
|
160
203
|
if (url === "/mcp") {
|
|
161
204
|
handleMcp(req, res).catch((err) => {
|
|
162
|
-
|
|
205
|
+
const status = err.status ?? 500;
|
|
206
|
+
if (status !== 413)
|
|
207
|
+
console.error("mcp request failed:", err);
|
|
163
208
|
if (!res.headersSent)
|
|
164
|
-
json(res,
|
|
209
|
+
json(res, status, { error: status === 413 ? "body too large" : "internal error" });
|
|
165
210
|
else
|
|
166
211
|
res.end();
|
|
167
212
|
});
|
|
@@ -169,7 +214,29 @@ const httpServer = createServer((req, res) => {
|
|
|
169
214
|
}
|
|
170
215
|
json(res, 404, { error: "not found (use /mcp)" });
|
|
171
216
|
});
|
|
217
|
+
// A stray rejection must never take down a task serving many tenants.
|
|
218
|
+
process.on("unhandledRejection", (err) => {
|
|
219
|
+
console.error("solari-mcp: unhandled rejection (ignored):", err);
|
|
220
|
+
});
|
|
221
|
+
let shuttingDown = false;
|
|
222
|
+
async function shutdown(sig) {
|
|
223
|
+
if (shuttingDown)
|
|
224
|
+
return;
|
|
225
|
+
shuttingDown = true;
|
|
226
|
+
console.error(`solari-mcp: ${sig} — draining ${sessions.size} session(s)`);
|
|
227
|
+
httpServer.close();
|
|
228
|
+
clearInterval(idleSweep);
|
|
229
|
+
// Release every tenant's cloud resources; without this an ECS rollout leaks
|
|
230
|
+
// one pool slot + one pinned VM per live conversation.
|
|
231
|
+
await Promise.race([
|
|
232
|
+
Promise.allSettled([...sessions.keys()].map((id) => evict(id, sig))),
|
|
233
|
+
new Promise((r) => setTimeout(r, 20_000)),
|
|
234
|
+
]);
|
|
235
|
+
process.exit(0);
|
|
236
|
+
}
|
|
237
|
+
for (const sig of ["SIGTERM", "SIGINT"]) {
|
|
238
|
+
process.on(sig, () => void shutdown(sig));
|
|
239
|
+
}
|
|
172
240
|
httpServer.listen(PORT, () => {
|
|
173
|
-
console.error(`solari-mcp http listening on :${PORT} (browser gw ${BROWSER_URL}, `
|
|
174
|
-
`desktop gw ${process.env.SOLARI_BASE_URL ?? "https://api.getsolari.com"})`);
|
|
241
|
+
console.error(`solari-mcp http listening on :${PORT} (browser gw ${BROWSER_URL}, desktop gw ${DESKTOP_URL})`);
|
|
175
242
|
});
|
package/dist/server.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ type Kind = "sandbox" | "desktop";
|
|
|
7
7
|
interface Entry {
|
|
8
8
|
kind: Kind;
|
|
9
9
|
handle: any;
|
|
10
|
+
commands: any[];
|
|
10
11
|
}
|
|
11
12
|
export interface Registry {
|
|
12
13
|
sessions: Map<string, Entry>;
|
|
@@ -32,7 +33,19 @@ export declare function registerToolset(server: McpServer, toolset: Record<strin
|
|
|
32
33
|
export interface ServerParts {
|
|
33
34
|
server: McpServer;
|
|
34
35
|
browserReg: BrowserRegistry;
|
|
36
|
+
/** Sandbox/desktop registry — the caller must close these on teardown. */
|
|
37
|
+
vmReg: Registry;
|
|
38
|
+
browserCfg: BrowserConfig;
|
|
35
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Drop our control-channel sockets for every sandbox/desktop in a registry.
|
|
42
|
+
*
|
|
43
|
+
* This is required, not cosmetic: the gateway treats a session with any open
|
|
44
|
+
* control/stream connection as active and never expires it, so leaving the
|
|
45
|
+
* channel open defeats idle-pause and the VM bills indefinitely. We close
|
|
46
|
+
* (not kill) so the customer keeps their VM and its own idle policy applies.
|
|
47
|
+
*/
|
|
48
|
+
export declare function closeAllVmSessions(reg: Registry): Promise<void>;
|
|
36
49
|
export declare function buildServerParts(client?: SolariClient, browserCfg?: BrowserConfig): ServerParts;
|
|
37
50
|
export declare function buildServer(client?: SolariClient, browserCfg?: BrowserConfig): McpServer;
|
|
38
51
|
export declare function main(): Promise<void>;
|
package/dist/server.js
CHANGED
|
@@ -12,10 +12,18 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
12
12
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
13
13
|
import { z } from "zod";
|
|
14
14
|
import { SolariClient } from "@solarisdk/sdk";
|
|
15
|
-
import { makeBrowserToolset } from "./browser.js";
|
|
16
|
-
|
|
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 ??
|
|
@@ -220,13 +284,14 @@ export function buildServerParts(client, browserCfg) {
|
|
|
220
284
|
process.env.SOLARI_BASE_URL ??
|
|
221
285
|
"https://api.getsolari.com",
|
|
222
286
|
};
|
|
223
|
-
const server = new McpServer({ name: "solari-mcp", version: "0.
|
|
287
|
+
const server = new McpServer({ name: "solari-mcp", version: "0.3.0" });
|
|
224
288
|
const browserReg = { sessions: new Map() };
|
|
289
|
+
const vmReg = { sessions: new Map() };
|
|
225
290
|
registerToolset(server, {
|
|
226
|
-
...makeToolset(c,
|
|
291
|
+
...makeToolset(c, vmReg),
|
|
227
292
|
...makeBrowserToolset(bCfg, browserReg),
|
|
228
293
|
});
|
|
229
|
-
return { server, browserReg };
|
|
294
|
+
return { server, browserReg, vmReg, browserCfg: bCfg };
|
|
230
295
|
}
|
|
231
296
|
export function buildServer(client, browserCfg) {
|
|
232
297
|
return buildServerParts(client, browserCfg).server;
|
|
@@ -236,8 +301,34 @@ export async function main() {
|
|
|
236
301
|
console.error("solari-mcp: SOLARI_API_KEY is required");
|
|
237
302
|
process.exit(1);
|
|
238
303
|
}
|
|
239
|
-
|
|
240
|
-
|
|
304
|
+
// A stray rejection (e.g. a background command's exit promise) must never
|
|
305
|
+
// take the server down mid-session.
|
|
306
|
+
process.on("unhandledRejection", (err) => {
|
|
307
|
+
console.error("solari-mcp: unhandled rejection (ignored):", err);
|
|
308
|
+
});
|
|
309
|
+
const parts = buildServerParts();
|
|
310
|
+
// Without this, every exit (client quit, Ctrl-C, host restart) leaves browser
|
|
311
|
+
// sessions held to their TTL and VM control channels open — which pins the
|
|
312
|
+
// VMs active so they never idle-pause. Both bill until cleaned up.
|
|
313
|
+
let shuttingDown = false;
|
|
314
|
+
const shutdown = async (sig) => {
|
|
315
|
+
if (shuttingDown)
|
|
316
|
+
return;
|
|
317
|
+
shuttingDown = true;
|
|
318
|
+
console.error(`solari-mcp: ${sig} — releasing sessions`);
|
|
319
|
+
await Promise.race([
|
|
320
|
+
Promise.allSettled([
|
|
321
|
+
releaseAllBrowserSessions(parts.browserCfg, parts.browserReg),
|
|
322
|
+
closeAllVmSessions(parts.vmReg),
|
|
323
|
+
]),
|
|
324
|
+
new Promise((r) => setTimeout(r, 10_000)),
|
|
325
|
+
]);
|
|
326
|
+
process.exit(0);
|
|
327
|
+
};
|
|
328
|
+
for (const sig of ["SIGTERM", "SIGINT"]) {
|
|
329
|
+
process.on(sig, () => void shutdown(sig));
|
|
330
|
+
}
|
|
331
|
+
await parts.server.connect(new StdioServerTransport());
|
|
241
332
|
}
|
|
242
333
|
// Run only when executed as the binary (not when imported by tests).
|
|
243
334
|
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
|