@solarisdk/mcp 0.2.0 → 0.2.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 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)
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,162 @@
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
+ try {
33
+ const res = await fetch(`${BROWSER_URL}/profiles`, {
34
+ headers: { authorization: `Bearer ${key}` },
35
+ signal: AbortSignal.timeout(10_000),
36
+ });
37
+ if (res.ok) {
38
+ keyOkUntil.set(h, Date.now() + 60_000);
39
+ return true;
40
+ }
41
+ // Gateway auth can negative-cache a freshly minted key for a few seconds;
42
+ // treat only a definitive 401/403 as invalid.
43
+ return res.status !== 401 && res.status !== 403;
44
+ }
45
+ catch {
46
+ return true; // don't lock customers out on our own transient errors
47
+ }
48
+ }
49
+ function bearer(req) {
50
+ const m = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? "");
51
+ return m?.[1];
52
+ }
53
+ function json(res, status, body) {
54
+ res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body));
55
+ }
56
+ async function readBody(req) {
57
+ const chunks = [];
58
+ for await (const c of req)
59
+ chunks.push(c);
60
+ const raw = Buffer.concat(chunks).toString("utf8");
61
+ if (!raw)
62
+ return undefined;
63
+ return JSON.parse(raw);
64
+ }
65
+ async function evict(id, why) {
66
+ const s = sessions.get(id);
67
+ if (!s)
68
+ return;
69
+ sessions.delete(id);
70
+ // Disconnect any live puppeteer handles (the cloud sessions themselves are
71
+ // governed by their own idle/pause TTLs — we only drop our sockets).
72
+ for (const e of s.browserReg.sessions.values()) {
73
+ try {
74
+ await e.browser.disconnect();
75
+ }
76
+ catch {
77
+ /* already gone */
78
+ }
79
+ }
80
+ try {
81
+ await s.transport.close();
82
+ }
83
+ catch {
84
+ /* already closed */
85
+ }
86
+ console.error(`mcp session ${id} evicted (${why})`);
87
+ }
88
+ setInterval(() => {
89
+ const now = Date.now();
90
+ for (const [id, s] of sessions)
91
+ if (now - s.lastSeen > IDLE_MS)
92
+ void evict(id, "idle");
93
+ }, 60_000).unref();
94
+ async function handleMcp(req, res) {
95
+ const key = bearer(req);
96
+ if (!key || !key.startsWith("slr_live_")) {
97
+ json(res, 401, { error: "Authorization: Bearer slr_live_… required" });
98
+ return;
99
+ }
100
+ const sid = req.headers["mcp-session-id"];
101
+ if (sid) {
102
+ const s = sessions.get(sid);
103
+ if (!s) {
104
+ json(res, 404, { error: "unknown mcp-session-id (session may have been evicted)" });
105
+ return;
106
+ }
107
+ if (s.keyHash !== hash(key)) {
108
+ json(res, 403, { error: "mcp-session-id belongs to a different API key" });
109
+ return;
110
+ }
111
+ s.lastSeen = Date.now();
112
+ const body = req.method === "POST" ? await readBody(req) : undefined;
113
+ await s.transport.handleRequest(req, res, body);
114
+ return;
115
+ }
116
+ // No session id → must be a POST initialize.
117
+ if (req.method !== "POST") {
118
+ json(res, 400, { error: "mcp-session-id header required" });
119
+ return;
120
+ }
121
+ if (!(await validateKey(key))) {
122
+ json(res, 401, { error: "invalid Solari API key" });
123
+ return;
124
+ }
125
+ const body = await readBody(req);
126
+ const { server, browserReg } = buildServerParts(undefined, {
127
+ apiKey: key,
128
+ baseUrl: BROWSER_URL,
129
+ });
130
+ const transport = new StreamableHTTPServerTransport({
131
+ sessionIdGenerator: randomUUID,
132
+ onsessioninitialized: (id) => {
133
+ sessions.set(id, { transport, browserReg, keyHash: hash(key), lastSeen: Date.now() });
134
+ console.error(`mcp session ${id} started (key …${key.slice(-4)})`);
135
+ },
136
+ onsessionclosed: (id) => void evict(id, "client closed"),
137
+ });
138
+ await server.connect(transport);
139
+ await transport.handleRequest(req, res, body);
140
+ }
141
+ const httpServer = createServer((req, res) => {
142
+ const url = req.url?.split("?")[0];
143
+ if (url === "/health" || url === "/healthz") {
144
+ json(res, 200, { ok: true, sessions: sessions.size });
145
+ return;
146
+ }
147
+ if (url === "/mcp") {
148
+ handleMcp(req, res).catch((err) => {
149
+ console.error("mcp request failed:", err);
150
+ if (!res.headersSent)
151
+ json(res, 500, { error: "internal error" });
152
+ else
153
+ res.end();
154
+ });
155
+ return;
156
+ }
157
+ json(res, 404, { error: "not found (use /mcp)" });
158
+ });
159
+ 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"})`);
162
+ });
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,
@@ -224,7 +224,10 @@ export function buildServer(client, browserCfg) {
224
224
  ...makeToolset(c, { sessions: new Map() }),
225
225
  ...makeBrowserToolset(bCfg, browserReg),
226
226
  });
227
- return server;
227
+ return { server, browserReg };
228
+ }
229
+ export function buildServer(client, browserCfg) {
230
+ return buildServerParts(client, browserCfg).server;
228
231
  }
229
232
  export async function main() {
230
233
  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.1",
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": {