@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 +42 -1
- package/dist/browser.js +8 -1
- package/dist/http.d.ts +2 -0
- package/dist/http.js +162 -0
- package/dist/server.d.ts +6 -1
- package/dist/server.js +6 -3
- 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)
|
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,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
|
|
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.
|
|
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": {
|