@solarisdk/mcp 0.2.0 → 0.2.2

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 CHANGED
@@ -15,7 +15,48 @@ agents (Claude Desktop **incl. Cowork**, Claude Code, Cursor, Windsurf, …) use
15
15
  MCP tool calls are stateless, so the server keeps an in-memory **session
16
16
  registry**: `*_create` tools return a `sessionId`, and the rest take it.
17
17
 
18
- ## Use it (Claude Desktop / Cowork / Cursor / Windsurf)
18
+ ## Use it hosted connector (no install)
19
+
20
+ Point any MCP client at the hosted Streamable HTTP endpoint with your API key:
21
+
22
+ - **Prod:** `https://mcp.getsolari.com/mcp`
23
+ - **Staging:** `https://mcp-sta.solaribrowser.com/mcp`
24
+
25
+ Claude Desktop / claude.ai: **Settings → Connectors → Add custom connector**,
26
+ URL as above, and send `Authorization: Bearer slr_live_…`. Or in
27
+ `claude_desktop_config.json` / Claude Code:
28
+
29
+ ```jsonc
30
+ {
31
+ "mcpServers": {
32
+ "solari": {
33
+ "type": "http",
34
+ "url": "https://mcp.getsolari.com/mcp",
35
+ "headers": { "Authorization": "Bearer slr_live_…" }
36
+ }
37
+ }
38
+ }
39
+ ```
40
+
41
+ Each MCP session is bound to the API key that initialized it; sessions idle
42
+ longer than 30 min are evicted server-side (your cloud sessions keep their own
43
+ lifecycles — re-attach with `solari_connect`).
44
+
45
+ ## Use it — local stdio (Claude Desktop / Cowork / Cursor / Windsurf)
46
+
47
+ ```jsonc
48
+ {
49
+ "mcpServers": {
50
+ "solari": {
51
+ "command": "npx",
52
+ "args": ["-y", "@solarisdk/mcp"],
53
+ "env": { "SOLARI_API_KEY": "slr_live_…" }
54
+ }
55
+ }
56
+ }
57
+ ```
58
+
59
+ ## Use it — single-file bundle (no npm)
19
60
 
20
61
  ```jsonc
21
62
  // claude_desktop_config.json (Claude Desktop: Developer → Edit Config)
@@ -45,6 +86,7 @@ Chat, Code and Cowork alike.
45
86
  | `SOLARI_API_KEY` | ✅ | — | unified `slr_live_…` key (works on both gateways) |
46
87
  | `SOLARI_BASE_URL` | | `https://api.getsolari.com` | sandbox/desktop gateway |
47
88
  | `SOLARI_BROWSER_URL` | | `SOLARI_BASE_URL` → prod | cloud-browser gateway (differs from `SOLARI_BASE_URL` on staging) |
89
+ | `SOLARI_BROWSER_API_KEY` | | `SOLARI_API_KEY` | separate key for the browser gateway, for environments where the browser and desktop key stores are split |
48
90
 
49
91
  ## Tools
50
92
 
package/dist/browser.js CHANGED
@@ -258,7 +258,14 @@ export function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
258
258
  }
259
259
  reg.sessions.delete(id);
260
260
  }
261
- const res = await api(cfg, "DELETE", `/sessions/${encodeURIComponent(id)}`);
261
+ // Gateway bearer verification can transiently 401 (control-plane
262
+ // verify blip / auth-cache churn); a failed release leaks the pool
263
+ // slot until TTL, so retry once before surfacing the error.
264
+ let res = await api(cfg, "DELETE", `/sessions/${encodeURIComponent(id)}`);
265
+ if (res.status === 401) {
266
+ await new Promise((r) => setTimeout(r, 1500));
267
+ res = await api(cfg, "DELETE", `/sessions/${encodeURIComponent(id)}`);
268
+ }
262
269
  // 204 = released; bare 404 = already gone (fine); 404 + InvalidSessionId = real failure.
263
270
  if (res.status === 404) {
264
271
  let code;
package/dist/http.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/http.js ADDED
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ // solari-mcp over Streamable HTTP — the hosted, multi-tenant variant.
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).
9
+ //
10
+ // PORT listen port (default 8080)
11
+ // SOLARI_BASE_URL sandbox/desktop gateway (default https://api.getsolari.com)
12
+ // SOLARI_BROWSER_URL browser gateway (default = SOLARI_BASE_URL)
13
+ // SESSION_IDLE_MS evict MCP sessions idle longer than this (default 30 min)
14
+ import { createServer } from "node:http";
15
+ import { createHash, randomUUID } from "node:crypto";
16
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
17
+ import { buildServerParts } from "./server.js";
18
+ const PORT = Number(process.env.PORT ?? 8080);
19
+ const IDLE_MS = Number(process.env.SESSION_IDLE_MS ?? 30 * 60 * 1000);
20
+ const BROWSER_URL = process.env.SOLARI_BROWSER_URL ?? process.env.SOLARI_BASE_URL ?? "https://api.getsolari.com";
21
+ const sessions = new Map();
22
+ const hash = (key) => createHash("sha256").update(key).digest("hex");
23
+ // Cheap upfront key validation (the gateways are the real authority; this just
24
+ // gives a clean 401 at connect time instead of failures on the first tool
25
+ // call). Positive results cached briefly to absorb client reconnect storms.
26
+ const keyOkUntil = new Map();
27
+ async function validateKey(key) {
28
+ const h = hash(key);
29
+ const until = keyOkUntil.get(h);
30
+ if (until && until > Date.now())
31
+ return true;
32
+ // Prod's browser and desktop gateways have separate key stores — a key is
33
+ // acceptable if EITHER accepts it (its tool calls on the other surface will
34
+ // fail with that gateway's own auth error).
35
+ const probes = [
36
+ `${BROWSER_URL}/profiles`,
37
+ `${process.env.SOLARI_BASE_URL ?? "https://api.getsolari.com"}/sandboxes?limit=1`,
38
+ ];
39
+ let sawDefinitiveReject = false;
40
+ for (const url of probes) {
41
+ try {
42
+ const res = await fetch(url, {
43
+ headers: { authorization: `Bearer ${key}` },
44
+ signal: AbortSignal.timeout(10_000),
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.
53
+ }
54
+ catch {
55
+ /* transient — inconclusive */
56
+ }
57
+ }
58
+ // Reject only when at least one gateway definitively said no and none said
59
+ // yes; if everything was inconclusive, don't lock customers out on our blip.
60
+ return !sawDefinitiveReject;
61
+ }
62
+ function bearer(req) {
63
+ const m = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? "");
64
+ return m?.[1];
65
+ }
66
+ function json(res, status, body) {
67
+ res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body));
68
+ }
69
+ async function readBody(req) {
70
+ const chunks = [];
71
+ for await (const c of req)
72
+ chunks.push(c);
73
+ const raw = Buffer.concat(chunks).toString("utf8");
74
+ if (!raw)
75
+ return undefined;
76
+ return JSON.parse(raw);
77
+ }
78
+ async function evict(id, why) {
79
+ const s = sessions.get(id);
80
+ if (!s)
81
+ return;
82
+ sessions.delete(id);
83
+ // Disconnect any live puppeteer handles (the cloud sessions themselves are
84
+ // governed by their own idle/pause TTLs — we only drop our sockets).
85
+ for (const e of s.browserReg.sessions.values()) {
86
+ try {
87
+ await e.browser.disconnect();
88
+ }
89
+ catch {
90
+ /* already gone */
91
+ }
92
+ }
93
+ try {
94
+ await s.transport.close();
95
+ }
96
+ catch {
97
+ /* already closed */
98
+ }
99
+ console.error(`mcp session ${id} evicted (${why})`);
100
+ }
101
+ setInterval(() => {
102
+ const now = Date.now();
103
+ for (const [id, s] of sessions)
104
+ if (now - s.lastSeen > IDLE_MS)
105
+ void evict(id, "idle");
106
+ }, 60_000).unref();
107
+ async function handleMcp(req, res) {
108
+ const key = bearer(req);
109
+ if (!key || !key.startsWith("slr_live_")) {
110
+ json(res, 401, { error: "Authorization: Bearer slr_live_… required" });
111
+ return;
112
+ }
113
+ const sid = req.headers["mcp-session-id"];
114
+ if (sid) {
115
+ const s = sessions.get(sid);
116
+ if (!s) {
117
+ json(res, 404, { error: "unknown mcp-session-id (session may have been evicted)" });
118
+ return;
119
+ }
120
+ if (s.keyHash !== hash(key)) {
121
+ json(res, 403, { error: "mcp-session-id belongs to a different API key" });
122
+ return;
123
+ }
124
+ s.lastSeen = Date.now();
125
+ const body = req.method === "POST" ? await readBody(req) : undefined;
126
+ await s.transport.handleRequest(req, res, body);
127
+ return;
128
+ }
129
+ // No session id → must be a POST initialize.
130
+ if (req.method !== "POST") {
131
+ json(res, 400, { error: "mcp-session-id header required" });
132
+ return;
133
+ }
134
+ if (!(await validateKey(key))) {
135
+ json(res, 401, { error: "invalid Solari API key" });
136
+ return;
137
+ }
138
+ const body = await readBody(req);
139
+ const { server, browserReg } = buildServerParts(undefined, {
140
+ apiKey: key,
141
+ baseUrl: BROWSER_URL,
142
+ });
143
+ const transport = new StreamableHTTPServerTransport({
144
+ sessionIdGenerator: randomUUID,
145
+ onsessioninitialized: (id) => {
146
+ sessions.set(id, { transport, browserReg, keyHash: hash(key), lastSeen: Date.now() });
147
+ console.error(`mcp session ${id} started (key …${key.slice(-4)})`);
148
+ },
149
+ onsessionclosed: (id) => void evict(id, "client closed"),
150
+ });
151
+ await server.connect(transport);
152
+ await transport.handleRequest(req, res, body);
153
+ }
154
+ const httpServer = createServer((req, res) => {
155
+ const url = req.url?.split("?")[0];
156
+ if (url === "/health" || url === "/healthz") {
157
+ json(res, 200, { ok: true, sessions: sessions.size });
158
+ return;
159
+ }
160
+ if (url === "/mcp") {
161
+ handleMcp(req, res).catch((err) => {
162
+ console.error("mcp request failed:", err);
163
+ if (!res.headersSent)
164
+ json(res, 500, { error: "internal error" });
165
+ else
166
+ res.end();
167
+ });
168
+ return;
169
+ }
170
+ json(res, 404, { error: "not found (use /mcp)" });
171
+ });
172
+ 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"})`);
175
+ });
package/dist/server.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { type ZodTypeAny } from "zod";
4
4
  import { SolariClient } from "@solarisdk/sdk";
5
- import { type BrowserConfig } from "./browser.js";
5
+ import { type BrowserConfig, type BrowserRegistry } from "./browser.js";
6
6
  type Kind = "sandbox" | "desktop";
7
7
  interface Entry {
8
8
  kind: Kind;
@@ -29,6 +29,11 @@ export interface Tool {
29
29
  }
30
30
  export declare function makeToolset(client: SolariClient, reg: Registry): Record<string, Tool>;
31
31
  export declare function registerToolset(server: McpServer, toolset: Record<string, Tool>): void;
32
+ export interface ServerParts {
33
+ server: McpServer;
34
+ browserReg: BrowserRegistry;
35
+ }
36
+ export declare function buildServerParts(client?: SolariClient, browserCfg?: BrowserConfig): ServerParts;
32
37
  export declare function buildServer(client?: SolariClient, browserCfg?: BrowserConfig): McpServer;
33
38
  export declare function main(): Promise<void>;
34
39
  export {};
package/dist/server.js CHANGED
@@ -203,8 +203,8 @@ export function registerToolset(server, toolset) {
203
203
  async (args) => t.handler(args));
204
204
  }
205
205
  }
206
- export function buildServer(client, browserCfg) {
207
- const apiKey = process.env.SOLARI_API_KEY ?? "";
206
+ export function buildServerParts(client, browserCfg) {
207
+ const apiKey = browserCfg?.apiKey ?? process.env.SOLARI_API_KEY ?? "";
208
208
  const c = client ??
209
209
  new SolariClient({
210
210
  apiKey,
@@ -212,8 +212,10 @@ export function buildServer(client, browserCfg) {
212
212
  });
213
213
  // The cloud-browser gateway is a separate service from the sandbox/desktop
214
214
  // gateway (same domain in prod, different domains on staging).
215
+ // Prod's browser + desktop gateways have separate key stores today; allow a
216
+ // browser-specific key (falls back to the shared one).
215
217
  const bCfg = browserCfg ?? {
216
- apiKey,
218
+ apiKey: process.env.SOLARI_BROWSER_API_KEY ?? apiKey,
217
219
  baseUrl: process.env.SOLARI_BROWSER_URL ??
218
220
  process.env.SOLARI_BASE_URL ??
219
221
  "https://api.getsolari.com",
@@ -224,7 +226,10 @@ export function buildServer(client, browserCfg) {
224
226
  ...makeToolset(c, { sessions: new Map() }),
225
227
  ...makeBrowserToolset(bCfg, browserReg),
226
228
  });
227
- return server;
229
+ return { server, browserReg };
230
+ }
231
+ export function buildServer(client, browserCfg) {
232
+ return buildServerParts(client, browserCfg).server;
228
233
  }
229
234
  export async function main() {
230
235
  if (!process.env.SOLARI_API_KEY) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solarisdk/mcp",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Model Context Protocol server for the Solari cloud browser, sandboxes + desktops — drive them from Claude Desktop/Cowork, Claude Code, Cursor, etc.",
5
5
  "type": "module",
6
6
  "bin": {