@squidlerio/squidler-mcp 1.0.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 ADDED
@@ -0,0 +1,99 @@
1
+ # @squidlerio/mcp
2
+
3
+ MCP proxy that sits between an AI client (Claude, Cursor, etc.) and the remote Squidler MCP server. It forwards all tools, resources, and prompts transparently, while adding local Chrome session management for testing localhost URLs.
4
+
5
+ ## How it works
6
+
7
+ ```
8
+ AI Client (stdio) ←→ MCP Proxy ←→ Remote Squidler MCP (HTTP)
9
+
10
+ Local Chrome ←→ CDP Proxy (WebSocket)
11
+ ```
12
+
13
+ The proxy intercepts `test_case_run` calls — when local Chrome mode is enabled, it automatically creates a CDP session and routes the test through your local browser instead of the cloud worker's Chrome.
14
+
15
+ ## Quick Start
16
+
17
+ Run directly with npx — no install or API key needed:
18
+
19
+ ```bash
20
+ npx @squidlerio/mcp
21
+ ```
22
+
23
+ On first use, a browser window opens for you to sign in to Squidler. Your session is saved locally so you only need to do this once.
24
+
25
+ ### Claude Code
26
+
27
+ ```bash
28
+ claude mcp add squidler -- npx -y @squidlerio/mcp
29
+ ```
30
+
31
+ ### Cursor / Other MCP Clients
32
+
33
+ Add to your MCP client configuration:
34
+
35
+ ```json
36
+ {
37
+ "mcpServers": {
38
+ "squidler": {
39
+ "command": "npx",
40
+ "args": ["-y", "@squidlerio/mcp"]
41
+ }
42
+ }
43
+ }
44
+ ```
45
+
46
+ ## CLI Commands
47
+
48
+ ```bash
49
+ # Sign in to Squidler (happens automatically on first use)
50
+ squidler-mcp login
51
+
52
+ # Sign out and clear saved session
53
+ squidler-mcp logout
54
+
55
+ # Download Chrome headless shell for local testing
56
+ squidler-mcp download-chrome
57
+ ```
58
+
59
+ ## Local Session Tools
60
+
61
+ These tools are added by the proxy (not available on the remote server):
62
+
63
+ - **`local_session_start`** — Enable local Chrome mode. Accepts `headless` (boolean, default: true). Chrome is launched on the first `test_case_run`.
64
+ - **`local_session_stop`** — Disable local Chrome mode and stop any active session.
65
+ - **`local_session_status`** — Check if local Chrome mode is enabled and if a session is active.
66
+
67
+ When local Chrome mode is enabled, `test_case_run` automatically creates/recycles a CDP session and routes through your local Chrome. Back-to-back tests get a fresh Chrome instance each time.
68
+
69
+ ## Advanced: API Key Override
70
+
71
+ If you prefer to use an API key instead of OAuth login, set the `SQUIDLER_API_KEY` environment variable:
72
+
73
+ ```json
74
+ {
75
+ "mcpServers": {
76
+ "squidler": {
77
+ "command": "npx",
78
+ "args": ["-y", "@squidlerio/mcp"],
79
+ "env": {
80
+ "SQUIDLER_API_KEY": "your-api-key"
81
+ }
82
+ }
83
+ }
84
+ }
85
+ ```
86
+
87
+ | Variable | Required | Default | Description |
88
+ |---|---|---|---|
89
+ | `SQUIDLER_API_KEY` | No | — | API key override (skips OAuth login) |
90
+ | `SQUIDLER_API_URL` | No | `https://mcp.squidler.io` | Remote MCP server URL |
91
+
92
+ ## Development
93
+
94
+ ```bash
95
+ bun install
96
+ bun run start # Run CLI
97
+ bun run mcp-proxy # Run MCP proxy
98
+ bun run build # Build for npm publishing
99
+ ```
@@ -0,0 +1,370 @@
1
+ import { createRequire } from "node:module";
2
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
3
+
4
+ // src/chrome/download.ts
5
+ import * as fs from "fs";
6
+ import * as path from "path";
7
+ import * as os from "os";
8
+ import * as https from "https";
9
+ import * as unzipper from "unzipper";
10
+ var DEFAULT_CHROME_VERSION = "136.0.7103.59";
11
+ function getPlatform() {
12
+ const platform2 = os.platform();
13
+ const arch2 = os.arch();
14
+ if (platform2 === "darwin") {
15
+ return arch2 === "arm64" ? "mac-arm64" : "mac-x64";
16
+ } else if (platform2 === "linux") {
17
+ return "linux64";
18
+ } else if (platform2 === "win32") {
19
+ return "win64";
20
+ }
21
+ throw new Error(`Unsupported platform: ${platform2} ${arch2}`);
22
+ }
23
+ function getChromePath(extractDir, platform2) {
24
+ const baseName = "chrome-headless-shell-" + platform2;
25
+ switch (platform2) {
26
+ case "mac-arm64":
27
+ case "mac-x64":
28
+ return path.join(extractDir, baseName, "chrome-headless-shell");
29
+ case "linux64":
30
+ return path.join(extractDir, baseName, "chrome-headless-shell");
31
+ case "win64":
32
+ return path.join(extractDir, baseName, "chrome-headless-shell.exe");
33
+ default:
34
+ throw new Error(`Unknown platform: ${platform2}`);
35
+ }
36
+ }
37
+ async function downloadFile(url, destPath) {
38
+ return new Promise((resolve, reject) => {
39
+ const file = fs.createWriteStream(destPath);
40
+ const request = https.get(url, (response) => {
41
+ if (response.statusCode === 301 || response.statusCode === 302) {
42
+ const redirectUrl = response.headers.location;
43
+ if (redirectUrl) {
44
+ file.close();
45
+ fs.unlinkSync(destPath);
46
+ downloadFile(redirectUrl, destPath).then(resolve).catch(reject);
47
+ return;
48
+ }
49
+ }
50
+ if (response.statusCode !== 200) {
51
+ reject(new Error(`Failed to download: HTTP ${response.statusCode}`));
52
+ return;
53
+ }
54
+ const totalSize = parseInt(response.headers["content-length"] || "0", 10);
55
+ let downloadedSize = 0;
56
+ response.on("data", (chunk) => {
57
+ downloadedSize += chunk.length;
58
+ if (totalSize > 0) {
59
+ const percent = Math.round(downloadedSize / totalSize * 100);
60
+ process.stderr.write(`\rDownloading Chrome... ${percent}%`);
61
+ }
62
+ });
63
+ response.pipe(file);
64
+ file.on("finish", () => {
65
+ file.close();
66
+ console.error(`
67
+ Download complete.`);
68
+ resolve();
69
+ });
70
+ });
71
+ request.on("error", (err) => {
72
+ fs.unlink(destPath, () => {});
73
+ reject(err);
74
+ });
75
+ file.on("error", (err) => {
76
+ fs.unlink(destPath, () => {});
77
+ reject(err);
78
+ });
79
+ });
80
+ }
81
+ async function extractZip(zipPath, destDir) {
82
+ console.error("Extracting Chrome...");
83
+ return new Promise((resolve, reject) => {
84
+ fs.createReadStream(zipPath).pipe(unzipper.Extract({ path: destDir })).on("close", () => {
85
+ console.error("Extraction complete.");
86
+ resolve();
87
+ }).on("error", reject);
88
+ });
89
+ }
90
+ async function downloadChrome(options = {}) {
91
+ const version = options.version || process.env.SQUIDLER_CHROME_VERSION || DEFAULT_CHROME_VERSION;
92
+ const defaultCacheDir = path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"), "squidler", "chrome");
93
+ const cacheDir = options.cacheDir || defaultCacheDir;
94
+ const platform2 = getPlatform();
95
+ fs.mkdirSync(cacheDir, { recursive: true });
96
+ const versionDir = path.join(cacheDir, version);
97
+ const executablePath = getChromePath(versionDir, platform2);
98
+ if (fs.existsSync(executablePath)) {
99
+ console.error(`Using cached Chrome ${version}`);
100
+ return { executablePath, version };
101
+ }
102
+ const url = `https://storage.googleapis.com/chrome-for-testing-public/${version}/${platform2}/chrome-headless-shell-${platform2}.zip`;
103
+ const zipPath = path.join(cacheDir, `chrome-${version}-${platform2}.zip`);
104
+ console.error(`Downloading Chrome ${version} for ${platform2}...`);
105
+ try {
106
+ await downloadFile(url, zipPath);
107
+ await extractZip(zipPath, versionDir);
108
+ fs.unlinkSync(zipPath);
109
+ if (platform2 !== "win64") {
110
+ fs.chmodSync(executablePath, 493);
111
+ }
112
+ console.error(`Chrome ${version} installed at: ${executablePath}`);
113
+ return { executablePath, version };
114
+ } catch (error) {
115
+ if (fs.existsSync(zipPath)) {
116
+ fs.unlinkSync(zipPath);
117
+ }
118
+ throw error;
119
+ }
120
+ }
121
+
122
+ // src/version.ts
123
+ import { readFileSync } from "fs";
124
+ import { dirname, join as join2 } from "path";
125
+ import { fileURLToPath } from "url";
126
+ var __dirname2 = dirname(fileURLToPath(import.meta.url));
127
+ var pkg = JSON.parse(readFileSync(join2(__dirname2, "..", "package.json"), "utf-8"));
128
+ var VERSION = pkg.version;
129
+
130
+ // src/auth/token-store.ts
131
+ import * as fs2 from "fs";
132
+ import * as path2 from "path";
133
+ import * as os2 from "os";
134
+ function getDataDir() {
135
+ const base = process.env.XDG_DATA_HOME || path2.join(os2.homedir(), ".local", "share");
136
+ return path2.join(base, "squidler");
137
+ }
138
+ function getAuthFilePath() {
139
+ return path2.join(getDataDir(), "auth.json");
140
+ }
141
+ function readStore() {
142
+ const filePath = getAuthFilePath();
143
+ try {
144
+ const data = fs2.readFileSync(filePath, "utf-8");
145
+ return JSON.parse(data);
146
+ } catch {
147
+ return {};
148
+ }
149
+ }
150
+ function writeStore(store) {
151
+ const dir = getDataDir();
152
+ fs2.mkdirSync(dir, { recursive: true });
153
+ const filePath = getAuthFilePath();
154
+ fs2.writeFileSync(filePath, JSON.stringify(store, null, 2), { mode: 384 });
155
+ }
156
+ function loadStoredAuth(serverUrl) {
157
+ const store = readStore();
158
+ return store[serverUrl] || null;
159
+ }
160
+ function saveStoredAuth(auth) {
161
+ const store = readStore();
162
+ store[auth.server_url] = auth;
163
+ writeStore(store);
164
+ }
165
+ function clearStoredAuth(serverUrl) {
166
+ const store = readStore();
167
+ delete store[serverUrl];
168
+ writeStore(store);
169
+ }
170
+
171
+ // src/auth/oauth.ts
172
+ import * as crypto from "crypto";
173
+ import * as os3 from "os";
174
+ import { spawnSync } from "child_process";
175
+
176
+ // src/auth/callback-server.ts
177
+ import * as http from "http";
178
+ var SUCCESS_HTML = `<!DOCTYPE html>
179
+ <html><head><title>Authentication Successful</title></head>
180
+ <body style="font-family:system-ui,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0">
181
+ <div style="text-align:center"><h1>Authenticated!</h1><p>You can close this tab and return to your terminal.</p></div>
182
+ </body></html>`;
183
+ var ERROR_HTML = (msg) => `<!DOCTYPE html>
184
+ <html><head><title>Authentication Failed</title></head>
185
+ <body style="font-family:system-ui,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0">
186
+ <div style="text-align:center"><h1>Authentication Failed</h1><p>${msg}</p></div>
187
+ </body></html>`;
188
+ function startCallbackServer() {
189
+ return new Promise((resolve, reject) => {
190
+ let onResult;
191
+ let onError;
192
+ const resultPromise = new Promise((res, rej) => {
193
+ onResult = res;
194
+ onError = rej;
195
+ });
196
+ const server = http.createServer((req, res) => {
197
+ const url = new URL(req.url || "/", `http://127.0.0.1`);
198
+ if (url.pathname !== "/callback") {
199
+ res.writeHead(404);
200
+ res.end("Not found");
201
+ return;
202
+ }
203
+ const error = url.searchParams.get("error");
204
+ if (error) {
205
+ const desc = url.searchParams.get("error_description") || error;
206
+ res.writeHead(400, { "Content-Type": "text/html" });
207
+ res.end(ERROR_HTML(desc));
208
+ onError(new Error(`OAuth error: ${desc}`));
209
+ return;
210
+ }
211
+ const code = url.searchParams.get("code");
212
+ const state = url.searchParams.get("state");
213
+ if (!code || !state) {
214
+ res.writeHead(400, { "Content-Type": "text/html" });
215
+ res.end(ERROR_HTML("Missing code or state parameter"));
216
+ onError(new Error("Missing code or state in callback"));
217
+ return;
218
+ }
219
+ res.writeHead(200, { "Content-Type": "text/html" });
220
+ res.end(SUCCESS_HTML);
221
+ onResult({ code, state });
222
+ });
223
+ server.unref();
224
+ const timeout = setTimeout(() => {
225
+ server.close();
226
+ onError(new Error("Authentication timed out (5 minutes)"));
227
+ }, 5 * 60 * 1000);
228
+ server.on("error", reject);
229
+ server.listen(0, "127.0.0.1", () => {
230
+ const address = server.address();
231
+ if (!address || typeof address === "string") {
232
+ reject(new Error("Could not get callback server port"));
233
+ return;
234
+ }
235
+ resolve({
236
+ port: address.port,
237
+ waitForCallback: () => resultPromise,
238
+ close: () => {
239
+ clearTimeout(timeout);
240
+ server.close();
241
+ }
242
+ });
243
+ });
244
+ });
245
+ }
246
+
247
+ // src/auth/oauth.ts
248
+ async function discover(serverUrl) {
249
+ const res = await fetch(`${serverUrl}/.well-known/oauth-authorization-server`);
250
+ if (!res.ok) {
251
+ throw new Error(`OAuth discovery failed: HTTP ${res.status}`);
252
+ }
253
+ const metadata = await res.json();
254
+ if (!metadata.authorization_endpoint || !metadata.token_endpoint || !metadata.registration_endpoint) {
255
+ throw new Error("OAuth discovery response missing required endpoints");
256
+ }
257
+ return metadata;
258
+ }
259
+ async function registerClient(registrationEndpoint, redirectUri) {
260
+ const res = await fetch(registrationEndpoint, {
261
+ method: "POST",
262
+ headers: { "Content-Type": "application/json" },
263
+ body: JSON.stringify({
264
+ client_name: "squidler-mcp-cli",
265
+ redirect_uris: [redirectUri],
266
+ grant_types: ["authorization_code"],
267
+ response_types: ["code"],
268
+ token_endpoint_auth_method: "client_secret_post"
269
+ })
270
+ });
271
+ if (!res.ok) {
272
+ throw new Error(`Client registration failed: HTTP ${res.status}`);
273
+ }
274
+ return await res.json();
275
+ }
276
+ function generatePKCE() {
277
+ const verifier = crypto.randomBytes(32).toString("base64url");
278
+ const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
279
+ return { verifier, challenge };
280
+ }
281
+ function openBrowser(url) {
282
+ const platform3 = os3.platform();
283
+ const commands = platform3 === "darwin" ? [["open", [url]]] : platform3 === "win32" ? [["cmd", ["/c", "start", "", url]]] : [
284
+ ["xdg-open", [url]],
285
+ ["sensible-browser", [url]],
286
+ ["x-www-browser", [url]]
287
+ ];
288
+ for (const [cmd, args] of commands) {
289
+ const result = spawnSync(cmd, args, { stdio: "ignore", timeout: 5000 });
290
+ if (!result.error && result.status === 0) {
291
+ return true;
292
+ }
293
+ }
294
+ return false;
295
+ }
296
+ async function exchangeToken(tokenEndpoint, params) {
297
+ const body = new URLSearchParams({
298
+ grant_type: "authorization_code",
299
+ code: params.code,
300
+ redirect_uri: params.redirectUri,
301
+ client_id: params.clientId,
302
+ client_secret: params.clientSecret,
303
+ code_verifier: params.codeVerifier
304
+ });
305
+ const res = await fetch(tokenEndpoint, {
306
+ method: "POST",
307
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
308
+ body: body.toString()
309
+ });
310
+ if (!res.ok) {
311
+ const text = await res.text();
312
+ throw new Error(`Token exchange failed: HTTP ${res.status} - ${text}`);
313
+ }
314
+ const data = await res.json();
315
+ if (!data.access_token) {
316
+ throw new Error("Token response missing access_token");
317
+ }
318
+ return data.access_token;
319
+ }
320
+ async function authenticateWithOAuth(serverUrl) {
321
+ console.error("Discovering OAuth endpoints...");
322
+ const metadata = await discover(serverUrl);
323
+ const callback = await startCallbackServer();
324
+ const redirectUri = `http://127.0.0.1:${callback.port}/callback`;
325
+ try {
326
+ console.error("Registering client...");
327
+ const client = await registerClient(metadata.registration_endpoint, redirectUri);
328
+ const pkce = generatePKCE();
329
+ const state = crypto.randomBytes(16).toString("base64url");
330
+ const authUrl = new URL(metadata.authorization_endpoint);
331
+ authUrl.searchParams.set("response_type", "code");
332
+ authUrl.searchParams.set("client_id", client.client_id);
333
+ authUrl.searchParams.set("redirect_uri", redirectUri);
334
+ authUrl.searchParams.set("state", state);
335
+ authUrl.searchParams.set("code_challenge", pkce.challenge);
336
+ authUrl.searchParams.set("code_challenge_method", "S256");
337
+ const opened = openBrowser(authUrl.toString());
338
+ if (opened) {
339
+ console.error("Browser opened for authentication. Waiting...");
340
+ } else {
341
+ console.error(`Open this URL in your browser to authenticate:
342
+
343
+ ${authUrl.toString()}
344
+
345
+ Waiting for authentication...`);
346
+ }
347
+ const result = await callback.waitForCallback();
348
+ if (result.state !== state) {
349
+ throw new Error("OAuth state mismatch — possible CSRF attack");
350
+ }
351
+ console.error("Exchanging authorization code for token...");
352
+ const accessToken = await exchangeToken(metadata.token_endpoint, {
353
+ code: result.code,
354
+ redirectUri,
355
+ clientId: client.client_id,
356
+ clientSecret: client.client_secret,
357
+ codeVerifier: pkce.verifier
358
+ });
359
+ saveStoredAuth({
360
+ access_token: accessToken,
361
+ server_url: serverUrl,
362
+ created_at: new Date().toISOString()
363
+ });
364
+ return accessToken;
365
+ } finally {
366
+ callback.close();
367
+ }
368
+ }
369
+
370
+ export { __require, downloadChrome, VERSION, loadStoredAuth, clearStoredAuth, authenticateWithOAuth };