@solarisdk/mcp 0.2.0
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 +105 -0
- package/dist/browser.d.ts +39 -0
- package/dist/browser.js +281 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +10 -0
- package/dist/server.d.ts +34 -0
- package/dist/server.js +243 -0
- package/package.json +48 -0
package/README.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# solari-mcp
|
|
2
|
+
|
|
3
|
+
A [Model Context Protocol](https://modelcontextprotocol.io) server that lets AI
|
|
4
|
+
agents (Claude Desktop **incl. Cowork**, Claude Code, Cursor, Windsurf, …) use
|
|
5
|
+
**Solari as their browser, sandbox, and desktop**:
|
|
6
|
+
|
|
7
|
+
- **Cloud browser** — create stealth/proxied browser sessions, navigate, read
|
|
8
|
+
pages, screenshot, click/type, session replay (wraps the Solari browser
|
|
9
|
+
gateway + CDP via puppeteer-core).
|
|
10
|
+
- **Sandboxes** — headless microVMs: run commands/code, read/write files,
|
|
11
|
+
expose ports.
|
|
12
|
+
- **Desktops** — GUI microVMs: screenshot/click/type, launch apps, noVNC
|
|
13
|
+
stream URL.
|
|
14
|
+
|
|
15
|
+
MCP tool calls are stateless, so the server keeps an in-memory **session
|
|
16
|
+
registry**: `*_create` tools return a `sessionId`, and the rest take it.
|
|
17
|
+
|
|
18
|
+
## Use it (Claude Desktop / Cowork / Cursor / Windsurf)
|
|
19
|
+
|
|
20
|
+
```jsonc
|
|
21
|
+
// claude_desktop_config.json (Claude Desktop: Developer → Edit Config)
|
|
22
|
+
{
|
|
23
|
+
"mcpServers": {
|
|
24
|
+
"solari": {
|
|
25
|
+
"command": "node",
|
|
26
|
+
"args": ["/absolute/path/to/solari-mcp.bundle.cjs"],
|
|
27
|
+
"env": {
|
|
28
|
+
"SOLARI_API_KEY": "slr_live_…"
|
|
29
|
+
// optional overrides (defaults hit prod https://api.getsolari.com):
|
|
30
|
+
// "SOLARI_BASE_URL": "https://desktops-sta.solaribrowser.com", // sandbox/desktop gateway
|
|
31
|
+
// "SOLARI_BROWSER_URL": "https://api-sta.solaribrowser.com" // browser gateway
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Fully quit and reopen Claude Desktop after editing; the tools then show up in
|
|
39
|
+
Chat, Code and Cowork alike.
|
|
40
|
+
|
|
41
|
+
## Environment
|
|
42
|
+
|
|
43
|
+
| Var | Required | Default | Meaning |
|
|
44
|
+
|---|---|---|---|
|
|
45
|
+
| `SOLARI_API_KEY` | ✅ | — | unified `slr_live_…` key (works on both gateways) |
|
|
46
|
+
| `SOLARI_BASE_URL` | | `https://api.getsolari.com` | sandbox/desktop gateway |
|
|
47
|
+
| `SOLARI_BROWSER_URL` | | `SOLARI_BASE_URL` → prod | cloud-browser gateway (differs from `SOLARI_BASE_URL` on staging) |
|
|
48
|
+
|
|
49
|
+
## Tools
|
|
50
|
+
|
|
51
|
+
### Browser (Solari cloud browser)
|
|
52
|
+
|
|
53
|
+
| Tool | What it does |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `solari_browser_create` | Start a browser session (`stealth`, `proxy`, `captcha`, `recording`, `profileId`) → `sessionId` |
|
|
56
|
+
| `solari_browser_navigate` | Go to a URL → `{url, title, status}` |
|
|
57
|
+
| `solari_browser_read_page` | Page text / links / HTML (30k-char cap) |
|
|
58
|
+
| `solari_browser_screenshot` | JPEG screenshot (image content) |
|
|
59
|
+
| `solari_browser_click` / `solari_browser_type` / `solari_browser_key` | Interact (selector or x/y) |
|
|
60
|
+
| `solari_browser_evaluate` | Run a JS expression on the page |
|
|
61
|
+
| `solari_browser_replay_url` | rrweb replay URL (needs `recording: true`) |
|
|
62
|
+
| `solari_browser_close` | Release the session |
|
|
63
|
+
|
|
64
|
+
### Sandboxes + desktops
|
|
65
|
+
|
|
66
|
+
| Tool | What it does |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `solari_sandbox_create` | Create a headless sandbox → `sessionId` |
|
|
69
|
+
| `solari_desktop_create` | Create a GUI desktop → `sessionId` + `streamUrl` |
|
|
70
|
+
| `solari_list` | List the org's sandboxes |
|
|
71
|
+
| `solari_kill` | Destroy a session |
|
|
72
|
+
| `solari_connect` | Re-attach to a session by id across restarts (auto-resumes if paused) |
|
|
73
|
+
| `solari_exec` | Run a shell command (via `sh -c`) → `{stdout,stderr,exitCode}` |
|
|
74
|
+
| `solari_run_command_bg` | Start a background shell command → `{cmdId}` |
|
|
75
|
+
| `solari_run_code` | Run code (default Python) → results incl. structured charts |
|
|
76
|
+
| `solari_read_file` / `solari_write_file` / `solari_list_files` | Guest filesystem |
|
|
77
|
+
| `solari_get_preview_url` | Public preview URL for an in-guest port |
|
|
78
|
+
| `solari_screenshot` | Capture the desktop as a PNG (image content) |
|
|
79
|
+
| `solari_click` / `solari_type` / `solari_key` | Desktop mouse/keyboard |
|
|
80
|
+
| `solari_open_app` | Launch an app on the desktop |
|
|
81
|
+
|
|
82
|
+
(The desktop GUI tools require a `solari_desktop_create` session; they error on
|
|
83
|
+
a sandbox. The browser tools are independent of both.)
|
|
84
|
+
|
|
85
|
+
## Develop
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
npm install # installs @modelcontextprotocol/sdk, puppeteer-core + the local SDK
|
|
89
|
+
npm run build # tsc → dist/
|
|
90
|
+
npm test # mock-client toolset tests (no network)
|
|
91
|
+
npm run build:bundle # esbuild → dist/solari-mcp.bundle.cjs (single file, no node_modules)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
**Live smoke (manual, hits real pools):**
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
SOLARI_API_KEY=slr_live_… \
|
|
98
|
+
SOLARI_BASE_URL=https://desktops-sta.solaribrowser.com \
|
|
99
|
+
SOLARI_BROWSER_URL=https://api-sta.solaribrowser.com \
|
|
100
|
+
node test/live-smoke.mjs # add SMOKE_SERVER=dist/solari-mcp.bundle.cjs to test the bundle
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Round-trips browser create→navigate→read→screenshot→search→close, sandbox
|
|
104
|
+
create→exec→run_code→kill, and desktop create→screenshot→kill as a real MCP
|
|
105
|
+
client over stdio.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type Browser, type Page } from "puppeteer-core";
|
|
2
|
+
import { type ZodTypeAny } from "zod";
|
|
3
|
+
export interface McpResult {
|
|
4
|
+
content: Array<{
|
|
5
|
+
type: "text";
|
|
6
|
+
text: string;
|
|
7
|
+
} | {
|
|
8
|
+
type: "image";
|
|
9
|
+
data: string;
|
|
10
|
+
mimeType: string;
|
|
11
|
+
}>;
|
|
12
|
+
isError?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export interface Tool {
|
|
15
|
+
description: string;
|
|
16
|
+
inputSchema: Record<string, ZodTypeAny>;
|
|
17
|
+
handler: (args: Record<string, unknown>) => Promise<McpResult>;
|
|
18
|
+
}
|
|
19
|
+
export interface BrowserConfig {
|
|
20
|
+
apiKey: string;
|
|
21
|
+
baseUrl: string;
|
|
22
|
+
}
|
|
23
|
+
interface BrowserEntry {
|
|
24
|
+
sessionId: string;
|
|
25
|
+
browser: Browser;
|
|
26
|
+
page: Page;
|
|
27
|
+
expiresAt: string;
|
|
28
|
+
recording: boolean;
|
|
29
|
+
}
|
|
30
|
+
export interface BrowserRegistry {
|
|
31
|
+
sessions: Map<string, BrowserEntry>;
|
|
32
|
+
}
|
|
33
|
+
declare function api(cfg: BrowserConfig, method: string, path: string, body?: unknown): Promise<Response>;
|
|
34
|
+
export interface BrowserDeps {
|
|
35
|
+
connect: (cdpEndpoint: string) => Promise<Browser>;
|
|
36
|
+
fetchApi: typeof api;
|
|
37
|
+
}
|
|
38
|
+
export declare function makeBrowserToolset(cfg: BrowserConfig, reg: BrowserRegistry, deps?: BrowserDeps): Record<string, Tool>;
|
|
39
|
+
export {};
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// Solari cloud-browser toolset for solari-mcp.
|
|
2
|
+
//
|
|
3
|
+
// The browser gateway is a session control plane only (create/release); all
|
|
4
|
+
// page-level actions here are driven client-side over the session's raw CDP
|
|
5
|
+
// endpoint via puppeteer-core. We keep the connected Browser handle for the
|
|
6
|
+
// session's lifetime — the signed ws URL cannot be re-dialed after 90 minutes.
|
|
7
|
+
import puppeteer from "puppeteer-core";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
const text = (o) => ({
|
|
10
|
+
content: [{ type: "text", text: typeof o === "string" ? o : JSON.stringify(o, null, 2) }],
|
|
11
|
+
});
|
|
12
|
+
const MAX_PAGE_TEXT = 30_000;
|
|
13
|
+
async function api(cfg, method, path, body) {
|
|
14
|
+
// Session create can block up to 60s gateway-side waiting for a slot.
|
|
15
|
+
const res = await fetch(`${cfg.baseUrl}${path}`, {
|
|
16
|
+
method,
|
|
17
|
+
headers: {
|
|
18
|
+
authorization: `Bearer ${cfg.apiKey}`,
|
|
19
|
+
...(body ? { "content-type": "application/json" } : {}),
|
|
20
|
+
},
|
|
21
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
22
|
+
signal: AbortSignal.timeout(90_000),
|
|
23
|
+
});
|
|
24
|
+
return res;
|
|
25
|
+
}
|
|
26
|
+
async function apiError(res, what) {
|
|
27
|
+
let detail = "";
|
|
28
|
+
try {
|
|
29
|
+
detail = await res.text();
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
/* body unavailable */
|
|
33
|
+
}
|
|
34
|
+
throw new Error(`${what} failed: HTTP ${res.status} ${detail.slice(0, 500)}`);
|
|
35
|
+
}
|
|
36
|
+
const defaultDeps = {
|
|
37
|
+
connect: (cdpEndpoint) => puppeteer.connect({
|
|
38
|
+
browserWSEndpoint: cdpEndpoint,
|
|
39
|
+
defaultViewport: { width: 1280, height: 800 },
|
|
40
|
+
}),
|
|
41
|
+
fetchApi: api,
|
|
42
|
+
};
|
|
43
|
+
export function makeBrowserToolset(cfg, reg, deps = defaultDeps) {
|
|
44
|
+
const api = deps.fetchApi;
|
|
45
|
+
const need = (id) => {
|
|
46
|
+
const e = reg.sessions.get(id);
|
|
47
|
+
if (!e)
|
|
48
|
+
throw new Error(`unknown browser sessionId: ${id} (create one with solari_browser_create)`);
|
|
49
|
+
return e;
|
|
50
|
+
};
|
|
51
|
+
// The agent may have navigated into a popup/new tab; act on the most
|
|
52
|
+
// recently opened page that is still attached.
|
|
53
|
+
const activePage = async (e) => {
|
|
54
|
+
if (!e.page.isClosed())
|
|
55
|
+
return e.page;
|
|
56
|
+
const pages = await e.browser.pages();
|
|
57
|
+
if (pages.length === 0)
|
|
58
|
+
throw new Error("no open pages in this browser session");
|
|
59
|
+
e.page = pages[pages.length - 1];
|
|
60
|
+
return e.page;
|
|
61
|
+
};
|
|
62
|
+
return {
|
|
63
|
+
solari_browser_create: {
|
|
64
|
+
description: "Start a Solari cloud browser session. Use this whenever you need to browse the web. " +
|
|
65
|
+
"Returns a sessionId for the other solari_browser_* tools. Options: stealth (anti-bot " +
|
|
66
|
+
"hardened pool), proxy ('smart' | country code like 'us' — requires stealth), captcha " +
|
|
67
|
+
"auto-solving (requires stealth), recording (rrweb replay).",
|
|
68
|
+
inputSchema: {
|
|
69
|
+
stealth: z.boolean().optional(),
|
|
70
|
+
proxy: z.string().optional(),
|
|
71
|
+
captcha: z.boolean().optional(),
|
|
72
|
+
recording: z.boolean().optional(),
|
|
73
|
+
profileId: z.string().optional(),
|
|
74
|
+
},
|
|
75
|
+
handler: async (a) => {
|
|
76
|
+
const body = {};
|
|
77
|
+
if (a.stealth)
|
|
78
|
+
body.stealth = true;
|
|
79
|
+
if (a.captcha)
|
|
80
|
+
body.captcha = true;
|
|
81
|
+
if (a.recording)
|
|
82
|
+
body.recording = true;
|
|
83
|
+
if (a.proxy)
|
|
84
|
+
body.proxy = a.proxy;
|
|
85
|
+
if (a.profileId)
|
|
86
|
+
body.profileId = a.profileId;
|
|
87
|
+
const res = await api(cfg, "POST", "/sessions", body);
|
|
88
|
+
if (res.status !== 201)
|
|
89
|
+
await apiError(res, "session create");
|
|
90
|
+
const s = (await res.json());
|
|
91
|
+
const browser = await deps.connect(s.cdpEndpoint);
|
|
92
|
+
const pages = await browser.pages();
|
|
93
|
+
const page = pages[0] ?? (await browser.newPage());
|
|
94
|
+
reg.sessions.set(s.sessionId, {
|
|
95
|
+
sessionId: s.sessionId,
|
|
96
|
+
browser,
|
|
97
|
+
page,
|
|
98
|
+
expiresAt: s.expiresAt,
|
|
99
|
+
recording: Boolean(a.recording),
|
|
100
|
+
});
|
|
101
|
+
// Proxy silently degrades rather than erroring — surface what we got.
|
|
102
|
+
return text({
|
|
103
|
+
sessionId: s.sessionId,
|
|
104
|
+
expiresAt: s.expiresAt,
|
|
105
|
+
proxy: s.proxy ?? (a.proxy ? "NOT APPLIED (check plan/stealth)" : undefined),
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
solari_browser_navigate: {
|
|
110
|
+
description: "Navigate the browser session to a URL. Returns final url, title and HTTP status.",
|
|
111
|
+
inputSchema: { sessionId: z.string(), url: z.string() },
|
|
112
|
+
handler: async (a) => {
|
|
113
|
+
const page = await activePage(need(a.sessionId));
|
|
114
|
+
const resp = await page.goto(a.url, { waitUntil: "domcontentloaded", timeout: 60_000 });
|
|
115
|
+
return text({ url: page.url(), title: await page.title(), status: resp?.status() ?? null });
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
solari_browser_read_page: {
|
|
119
|
+
description: "Read the current page. format 'text' (default) returns visible text; 'links' returns " +
|
|
120
|
+
"clickable links {text, href}; 'html' returns raw HTML. Output is truncated to 30k chars.",
|
|
121
|
+
inputSchema: {
|
|
122
|
+
sessionId: z.string(),
|
|
123
|
+
format: z.enum(["text", "links", "html"]).optional(),
|
|
124
|
+
},
|
|
125
|
+
handler: async (a) => {
|
|
126
|
+
const page = await activePage(need(a.sessionId));
|
|
127
|
+
const fmt = a.format ?? "text";
|
|
128
|
+
let out;
|
|
129
|
+
if (fmt === "html") {
|
|
130
|
+
out = await page.content();
|
|
131
|
+
}
|
|
132
|
+
else if (fmt === "links") {
|
|
133
|
+
const links = await page.$$eval("a[href]", (as) => as
|
|
134
|
+
.map((el) => ({
|
|
135
|
+
text: (el.textContent ?? "").trim().slice(0, 120),
|
|
136
|
+
href: el.href,
|
|
137
|
+
}))
|
|
138
|
+
.filter((l) => l.text)
|
|
139
|
+
.slice(0, 200));
|
|
140
|
+
out = JSON.stringify(links, null, 2);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
out = await page.evaluate(() => document.body?.innerText ?? "");
|
|
144
|
+
}
|
|
145
|
+
const truncated = out.length > MAX_PAGE_TEXT;
|
|
146
|
+
return text(`${page.url()}\n\n${out.slice(0, MAX_PAGE_TEXT)}${truncated ? "\n…[truncated]" : ""}`);
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
solari_browser_screenshot: {
|
|
150
|
+
description: "Screenshot the browser session's current page (JPEG image).",
|
|
151
|
+
inputSchema: { sessionId: z.string(), fullPage: z.boolean().optional() },
|
|
152
|
+
handler: async (a) => {
|
|
153
|
+
const page = await activePage(need(a.sessionId));
|
|
154
|
+
const buf = await page.screenshot({
|
|
155
|
+
type: "jpeg",
|
|
156
|
+
quality: 75,
|
|
157
|
+
fullPage: Boolean(a.fullPage),
|
|
158
|
+
});
|
|
159
|
+
return {
|
|
160
|
+
content: [
|
|
161
|
+
{ type: "image", data: Buffer.from(buf).toString("base64"), mimeType: "image/jpeg" },
|
|
162
|
+
],
|
|
163
|
+
};
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
solari_browser_click: {
|
|
167
|
+
description: "Click on the page: give a CSS selector, or x/y viewport coordinates (e.g. from a screenshot).",
|
|
168
|
+
inputSchema: {
|
|
169
|
+
sessionId: z.string(),
|
|
170
|
+
selector: z.string().optional(),
|
|
171
|
+
x: z.number().optional(),
|
|
172
|
+
y: z.number().optional(),
|
|
173
|
+
},
|
|
174
|
+
handler: async (a) => {
|
|
175
|
+
const page = await activePage(need(a.sessionId));
|
|
176
|
+
if (a.selector) {
|
|
177
|
+
await page.click(a.selector);
|
|
178
|
+
}
|
|
179
|
+
else if (typeof a.x === "number" && typeof a.y === "number") {
|
|
180
|
+
await page.mouse.click(a.x, a.y);
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
throw new Error("provide selector or x+y");
|
|
184
|
+
}
|
|
185
|
+
return text({ ok: true, url: page.url() });
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
solari_browser_type: {
|
|
189
|
+
description: "Type text into the page. Optionally focus a CSS selector first. Set pressEnter to submit.",
|
|
190
|
+
inputSchema: {
|
|
191
|
+
sessionId: z.string(),
|
|
192
|
+
text: z.string(),
|
|
193
|
+
selector: z.string().optional(),
|
|
194
|
+
pressEnter: z.boolean().optional(),
|
|
195
|
+
},
|
|
196
|
+
handler: async (a) => {
|
|
197
|
+
const page = await activePage(need(a.sessionId));
|
|
198
|
+
if (a.selector)
|
|
199
|
+
await page.click(a.selector);
|
|
200
|
+
await page.keyboard.type(a.text, { delay: 20 });
|
|
201
|
+
if (a.pressEnter)
|
|
202
|
+
await page.keyboard.press("Enter");
|
|
203
|
+
return text({ ok: true });
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
solari_browser_key: {
|
|
207
|
+
description: "Press a key in the browser session (e.g. 'Enter', 'Escape', 'ArrowDown', 'PageDown').",
|
|
208
|
+
inputSchema: { sessionId: z.string(), key: z.string() },
|
|
209
|
+
handler: async (a) => {
|
|
210
|
+
const page = await activePage(need(a.sessionId));
|
|
211
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
212
|
+
await page.keyboard.press(a.key);
|
|
213
|
+
return text({ ok: true });
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
solari_browser_evaluate: {
|
|
217
|
+
description: "Evaluate a JavaScript expression on the page and return its JSON-serialized result.",
|
|
218
|
+
inputSchema: { sessionId: z.string(), expression: z.string() },
|
|
219
|
+
handler: async (a) => {
|
|
220
|
+
const page = await activePage(need(a.sessionId));
|
|
221
|
+
const result = await page.evaluate((expr) => {
|
|
222
|
+
// eslint-disable-next-line no-eval
|
|
223
|
+
const v = (0, eval)(expr);
|
|
224
|
+
try {
|
|
225
|
+
return JSON.parse(JSON.stringify(v ?? null));
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return String(v);
|
|
229
|
+
}
|
|
230
|
+
}, a.expression);
|
|
231
|
+
return text(result);
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
solari_browser_replay_url: {
|
|
235
|
+
description: "Get the session-replay URL for a browser session created with recording: true. " +
|
|
236
|
+
"May 404 for a few seconds right after close — retry.",
|
|
237
|
+
inputSchema: { sessionId: z.string() },
|
|
238
|
+
handler: async (a) => {
|
|
239
|
+
const id = a.sessionId;
|
|
240
|
+
const res = await api(cfg, "GET", `/sessions/${encodeURIComponent(id)}/replay-url`);
|
|
241
|
+
if (!res.ok)
|
|
242
|
+
await apiError(res, "replay-url");
|
|
243
|
+
return text(await res.json());
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
solari_browser_close: {
|
|
247
|
+
description: "Close a browser session and release it.",
|
|
248
|
+
inputSchema: { sessionId: z.string() },
|
|
249
|
+
handler: async (a) => {
|
|
250
|
+
const id = a.sessionId;
|
|
251
|
+
const e = reg.sessions.get(id);
|
|
252
|
+
if (e) {
|
|
253
|
+
try {
|
|
254
|
+
await e.browser.disconnect();
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
/* already gone */
|
|
258
|
+
}
|
|
259
|
+
reg.sessions.delete(id);
|
|
260
|
+
}
|
|
261
|
+
const res = await api(cfg, "DELETE", `/sessions/${encodeURIComponent(id)}`);
|
|
262
|
+
// 204 = released; bare 404 = already gone (fine); 404 + InvalidSessionId = real failure.
|
|
263
|
+
if (res.status === 404) {
|
|
264
|
+
let code;
|
|
265
|
+
try {
|
|
266
|
+
code = (await res.json()).code;
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
/* no body */
|
|
270
|
+
}
|
|
271
|
+
if (code === "InvalidSessionId")
|
|
272
|
+
throw new Error("release failed: gateway rejected sessionId");
|
|
273
|
+
}
|
|
274
|
+
else if (res.status !== 204) {
|
|
275
|
+
await apiError(res, "session release");
|
|
276
|
+
}
|
|
277
|
+
return text({ ok: true });
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// CLI + bundle entry point: unconditionally start the stdio server. The
|
|
3
|
+
// import.meta.url main-gate in server.ts fails under npm bin symlinks (argv[1]
|
|
4
|
+
// is the symlink, import.meta.url the real file) and inside the esbuild cjs
|
|
5
|
+
// bundle — so both routes come through here instead.
|
|
6
|
+
import { main } from "./server.js";
|
|
7
|
+
main().catch((err) => {
|
|
8
|
+
console.error("solari-mcp fatal:", err);
|
|
9
|
+
process.exit(1);
|
|
10
|
+
});
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { type ZodTypeAny } from "zod";
|
|
4
|
+
import { SolariClient } from "@solarisdk/sdk";
|
|
5
|
+
import { type BrowserConfig } from "./browser.js";
|
|
6
|
+
type Kind = "sandbox" | "desktop";
|
|
7
|
+
interface Entry {
|
|
8
|
+
kind: Kind;
|
|
9
|
+
handle: any;
|
|
10
|
+
}
|
|
11
|
+
export interface Registry {
|
|
12
|
+
sessions: Map<string, Entry>;
|
|
13
|
+
}
|
|
14
|
+
export interface McpResult {
|
|
15
|
+
content: Array<{
|
|
16
|
+
type: "text";
|
|
17
|
+
text: string;
|
|
18
|
+
} | {
|
|
19
|
+
type: "image";
|
|
20
|
+
data: string;
|
|
21
|
+
mimeType: string;
|
|
22
|
+
}>;
|
|
23
|
+
isError?: boolean;
|
|
24
|
+
}
|
|
25
|
+
export interface Tool {
|
|
26
|
+
description: string;
|
|
27
|
+
inputSchema: Record<string, ZodTypeAny>;
|
|
28
|
+
handler: (args: Record<string, unknown>) => Promise<McpResult>;
|
|
29
|
+
}
|
|
30
|
+
export declare function makeToolset(client: SolariClient, reg: Registry): Record<string, Tool>;
|
|
31
|
+
export declare function registerToolset(server: McpServer, toolset: Record<string, Tool>): void;
|
|
32
|
+
export declare function buildServer(client?: SolariClient, browserCfg?: BrowserConfig): McpServer;
|
|
33
|
+
export declare function main(): Promise<void>;
|
|
34
|
+
export {};
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// solari-mcp — a Model Context Protocol server that lets agents (Claude
|
|
3
|
+
// Desktop/Code, Cursor, Windsurf, …) drive Solari sandboxes + desktops.
|
|
4
|
+
//
|
|
5
|
+
// Stdio transport. Auth via SOLARI_API_KEY (+ optional SOLARI_BASE_URL).
|
|
6
|
+
// MCP tool calls are stateless, so the server keeps a session registry keyed by
|
|
7
|
+
// sessionId: create tools return an id, and the rest take it.
|
|
8
|
+
//
|
|
9
|
+
// The toolset is built as a plain map (makeToolset) so it can be unit-tested
|
|
10
|
+
// against a mock SolariClient without an MCP transport.
|
|
11
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
12
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { SolariClient } from "@solarisdk/sdk";
|
|
15
|
+
import { makeBrowserToolset } from "./browser.js";
|
|
16
|
+
const text = (o) => ({
|
|
17
|
+
content: [{ type: "text", text: typeof o === "string" ? o : JSON.stringify(o, null, 2) }],
|
|
18
|
+
});
|
|
19
|
+
export function makeToolset(client, reg) {
|
|
20
|
+
const need = (id) => {
|
|
21
|
+
const e = reg.sessions.get(id);
|
|
22
|
+
if (!e)
|
|
23
|
+
throw new Error(`unknown sessionId: ${id}`);
|
|
24
|
+
return e;
|
|
25
|
+
};
|
|
26
|
+
const live = async (id) => {
|
|
27
|
+
const e = need(id);
|
|
28
|
+
if (!e.handle.connected)
|
|
29
|
+
await e.handle.connect();
|
|
30
|
+
return e;
|
|
31
|
+
};
|
|
32
|
+
const desktop = async (id) => {
|
|
33
|
+
const e = await live(id);
|
|
34
|
+
if (e.kind !== "desktop")
|
|
35
|
+
throw new Error(`session ${id} is a sandbox; this tool needs a desktop`);
|
|
36
|
+
return e;
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
solari_sandbox_create: {
|
|
40
|
+
description: "Create a headless sandbox (microVM). Returns its sessionId.",
|
|
41
|
+
inputSchema: { template: z.string().optional(), cpu: z.number().optional(), memMb: z.number().optional() },
|
|
42
|
+
handler: async (a) => {
|
|
43
|
+
const sbx = await client.sandboxes.create({
|
|
44
|
+
...(a.template ? { template: a.template } : {}),
|
|
45
|
+
...(a.cpu ? { cpu: a.cpu } : {}),
|
|
46
|
+
...(a.memMb ? { memMb: a.memMb } : {}),
|
|
47
|
+
});
|
|
48
|
+
reg.sessions.set(sbx.sandboxId, { kind: "sandbox", handle: sbx });
|
|
49
|
+
return text({ sessionId: sbx.sandboxId });
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
solari_desktop_create: {
|
|
53
|
+
description: "Create a GUI desktop (microVM). Returns sessionId + streamUrl (noVNC).",
|
|
54
|
+
inputSchema: { template: z.string().optional(), resolution: z.string().optional() },
|
|
55
|
+
handler: async (a) => {
|
|
56
|
+
const d = await client.desktops.create({
|
|
57
|
+
...(a.template ? { template: a.template } : {}),
|
|
58
|
+
...(a.resolution ? { resolution: a.resolution } : {}),
|
|
59
|
+
});
|
|
60
|
+
reg.sessions.set(d.sessionId, { kind: "desktop", handle: d });
|
|
61
|
+
return text({ sessionId: d.sessionId, streamUrl: d.streamUrl });
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
solari_list: {
|
|
65
|
+
description: "List the org's sandboxes.",
|
|
66
|
+
inputSchema: {},
|
|
67
|
+
handler: async () => text(await client.sandboxes.list()),
|
|
68
|
+
},
|
|
69
|
+
solari_kill: {
|
|
70
|
+
description: "Destroy a session by id.",
|
|
71
|
+
inputSchema: { sessionId: z.string() },
|
|
72
|
+
handler: async (a) => {
|
|
73
|
+
const e = need(a.sessionId);
|
|
74
|
+
await e.handle.kill();
|
|
75
|
+
reg.sessions.delete(a.sessionId);
|
|
76
|
+
return text({ ok: true });
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
solari_exec: {
|
|
80
|
+
description: "Run a shell command in a session. Returns { stdout, stderr, exitCode }.",
|
|
81
|
+
inputSchema: { sessionId: z.string(), command: z.string() },
|
|
82
|
+
handler: async (a) => {
|
|
83
|
+
const e = await live(a.sessionId);
|
|
84
|
+
// Guest exec is no-shell (cmd = binary + argv); route through sh -c so
|
|
85
|
+
// pipes, $(), globs and quoting behave the way "shell command" implies.
|
|
86
|
+
return text(await e.handle.commands.run("sh", { args: ["-c", a.command] }));
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
solari_run_command_bg: {
|
|
90
|
+
description: "Start a shell command in the background (non-blocking). Returns its cmdId; the process keeps running after this call.",
|
|
91
|
+
inputSchema: { sessionId: z.string(), command: z.string() },
|
|
92
|
+
handler: async (a) => {
|
|
93
|
+
const e = await live(a.sessionId);
|
|
94
|
+
const h = await e.handle.commands.start("sh", { args: ["-c", a.command] });
|
|
95
|
+
return text({ cmdId: h.cmdId });
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
solari_get_preview_url: {
|
|
99
|
+
description: "Get a public preview URL for an in-guest port (e.g. a dev server on :3000). Returns { url, token? }.",
|
|
100
|
+
inputSchema: { sessionId: z.string(), port: z.number() },
|
|
101
|
+
handler: async (a) => {
|
|
102
|
+
const e = await live(a.sessionId);
|
|
103
|
+
return text(await e.handle.previewUrl(a.port));
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
solari_connect: {
|
|
107
|
+
description: "Re-attach to an existing session by id (across process restarts); auto-resumes it if paused. Registers it and returns { sessionId, kind }.",
|
|
108
|
+
inputSchema: { sessionId: z.string(), kind: z.enum(["sandbox", "desktop"]).optional() },
|
|
109
|
+
handler: async (a) => {
|
|
110
|
+
const id = a.sessionId;
|
|
111
|
+
const existing = reg.sessions.get(id);
|
|
112
|
+
const kind = a.kind ?? existing?.kind ?? "sandbox";
|
|
113
|
+
const handle = kind === "desktop"
|
|
114
|
+
? await client.desktops.connect(id)
|
|
115
|
+
: await client.sandboxes.connect(id);
|
|
116
|
+
reg.sessions.set(id, { kind, handle });
|
|
117
|
+
return text({ sessionId: id, kind });
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
solari_run_code: {
|
|
121
|
+
description: "Run code (default Python) in a session's kernel. Returns results incl. structured charts.",
|
|
122
|
+
inputSchema: { sessionId: z.string(), code: z.string(), language: z.string().optional() },
|
|
123
|
+
handler: async (a) => {
|
|
124
|
+
const e = await live(a.sessionId);
|
|
125
|
+
return text(await e.handle.runCode(a.code, a.language ? { language: a.language } : {}));
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
solari_read_file: {
|
|
129
|
+
description: "Read an in-guest text file.",
|
|
130
|
+
inputSchema: { sessionId: z.string(), path: z.string() },
|
|
131
|
+
handler: async (a) => {
|
|
132
|
+
const e = await live(a.sessionId);
|
|
133
|
+
return text(await e.handle.files.readText(a.path));
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
solari_write_file: {
|
|
137
|
+
description: "Write a text file into the guest.",
|
|
138
|
+
inputSchema: { sessionId: z.string(), path: z.string(), content: z.string() },
|
|
139
|
+
handler: async (a) => {
|
|
140
|
+
const e = await live(a.sessionId);
|
|
141
|
+
await e.handle.files.write(a.path, a.content);
|
|
142
|
+
return text({ ok: true });
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
solari_list_files: {
|
|
146
|
+
description: "List a guest directory.",
|
|
147
|
+
inputSchema: { sessionId: z.string(), path: z.string() },
|
|
148
|
+
handler: async (a) => {
|
|
149
|
+
const e = await live(a.sessionId);
|
|
150
|
+
return text(await e.handle.files.list(a.path));
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
solari_screenshot: {
|
|
154
|
+
description: "Capture the desktop screen as a PNG image.",
|
|
155
|
+
inputSchema: { sessionId: z.string() },
|
|
156
|
+
handler: async (a) => {
|
|
157
|
+
const e = await desktop(a.sessionId);
|
|
158
|
+
const png = await e.handle.screenshot();
|
|
159
|
+
return { content: [{ type: "image", data: Buffer.from(png).toString("base64"), mimeType: "image/png" }] };
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
solari_click: {
|
|
163
|
+
description: "Click the desktop at (x, y).",
|
|
164
|
+
inputSchema: { sessionId: z.string(), x: z.number(), y: z.number() },
|
|
165
|
+
handler: async (a) => {
|
|
166
|
+
const e = await desktop(a.sessionId);
|
|
167
|
+
await e.handle.mouse.click(a.x, a.y);
|
|
168
|
+
return text({ ok: true });
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
solari_type: {
|
|
172
|
+
description: "Type text into the focused desktop element.",
|
|
173
|
+
inputSchema: { sessionId: z.string(), text: z.string() },
|
|
174
|
+
handler: async (a) => {
|
|
175
|
+
const e = await desktop(a.sessionId);
|
|
176
|
+
await e.handle.keyboard.type(a.text);
|
|
177
|
+
return text({ ok: true });
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
solari_key: {
|
|
181
|
+
description: "Press a key or chord (e.g. 'Return', 'ctrl+c') on the desktop.",
|
|
182
|
+
inputSchema: { sessionId: z.string(), key: z.string() },
|
|
183
|
+
handler: async (a) => {
|
|
184
|
+
const e = await desktop(a.sessionId);
|
|
185
|
+
await e.handle.keyboard.press(a.key);
|
|
186
|
+
return text({ ok: true });
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
solari_open_app: {
|
|
190
|
+
description: "Launch an application on the desktop. Returns its pid.",
|
|
191
|
+
inputSchema: { sessionId: z.string(), name: z.string() },
|
|
192
|
+
handler: async (a) => {
|
|
193
|
+
const e = await desktop(a.sessionId);
|
|
194
|
+
return text({ pid: await e.handle.open(a.name) });
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
export function registerToolset(server, toolset) {
|
|
200
|
+
for (const [name, t] of Object.entries(toolset)) {
|
|
201
|
+
server.registerTool(name, { description: t.description, inputSchema: t.inputSchema },
|
|
202
|
+
// The MCP SDK passes validated args; our handlers accept a record.
|
|
203
|
+
async (args) => t.handler(args));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
export function buildServer(client, browserCfg) {
|
|
207
|
+
const apiKey = process.env.SOLARI_API_KEY ?? "";
|
|
208
|
+
const c = client ??
|
|
209
|
+
new SolariClient({
|
|
210
|
+
apiKey,
|
|
211
|
+
...(process.env.SOLARI_BASE_URL ? { baseUrl: process.env.SOLARI_BASE_URL } : {}),
|
|
212
|
+
});
|
|
213
|
+
// The cloud-browser gateway is a separate service from the sandbox/desktop
|
|
214
|
+
// gateway (same domain in prod, different domains on staging).
|
|
215
|
+
const bCfg = browserCfg ?? {
|
|
216
|
+
apiKey,
|
|
217
|
+
baseUrl: process.env.SOLARI_BROWSER_URL ??
|
|
218
|
+
process.env.SOLARI_BASE_URL ??
|
|
219
|
+
"https://api.getsolari.com",
|
|
220
|
+
};
|
|
221
|
+
const server = new McpServer({ name: "solari-mcp", version: "0.2.0" });
|
|
222
|
+
const browserReg = { sessions: new Map() };
|
|
223
|
+
registerToolset(server, {
|
|
224
|
+
...makeToolset(c, { sessions: new Map() }),
|
|
225
|
+
...makeBrowserToolset(bCfg, browserReg),
|
|
226
|
+
});
|
|
227
|
+
return server;
|
|
228
|
+
}
|
|
229
|
+
export async function main() {
|
|
230
|
+
if (!process.env.SOLARI_API_KEY) {
|
|
231
|
+
console.error("solari-mcp: SOLARI_API_KEY is required");
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
const server = buildServer();
|
|
235
|
+
await server.connect(new StdioServerTransport());
|
|
236
|
+
}
|
|
237
|
+
// Run only when executed as the binary (not when imported by tests).
|
|
238
|
+
if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
|
|
239
|
+
main().catch((err) => {
|
|
240
|
+
console.error("solari-mcp fatal:", err);
|
|
241
|
+
process.exit(1);
|
|
242
|
+
});
|
|
243
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@solarisdk/mcp",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Model Context Protocol server for the Solari cloud browser, sandboxes + desktops — drive them from Claude Desktop/Cowork, Claude Code, Cursor, etc.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"solari-mcp": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/server.js",
|
|
10
|
+
"files": [
|
|
11
|
+
"dist/*.js",
|
|
12
|
+
"dist/*.d.ts",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc",
|
|
17
|
+
"typecheck": "tsc --noEmit",
|
|
18
|
+
"test": "npm run build && node --test test/*.test.mjs",
|
|
19
|
+
"build:bundle": "esbuild src/cli.ts --bundle --platform=node --format=cjs --target=node18 --outfile=dist/solari-mcp.bundle.cjs --log-level=warning"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18"
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
27
|
+
"@solarisdk/sdk": "^0.1.2",
|
|
28
|
+
"puppeteer-core": "^24.43.1",
|
|
29
|
+
"zod": "^3.23.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^22.0.0",
|
|
33
|
+
"esbuild": "^0.28.1",
|
|
34
|
+
"typescript": "^5.6.0"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"mcp",
|
|
41
|
+
"modelcontextprotocol",
|
|
42
|
+
"solari",
|
|
43
|
+
"browser",
|
|
44
|
+
"sandbox",
|
|
45
|
+
"desktop",
|
|
46
|
+
"agent"
|
|
47
|
+
]
|
|
48
|
+
}
|