@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 +43 -1
- package/dist/browser.js +8 -1
- package/dist/http.d.ts +2 -0
- package/dist/http.js +175 -0
- package/dist/server.d.ts +6 -1
- package/dist/server.js +9 -4
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
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
|
|
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.
|
|
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": {
|