chuskybrowser-mcp 0.0.0-stage → 1.18.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 +240 -3
- package/lib/config.mjs +16 -0
- package/lib/cookies.mjs +147 -0
- package/lib/tool-contracts.mjs +780 -0
- package/package.json +48 -6
- package/server.mjs +118 -0
package/README.md
CHANGED
|
@@ -1,3 +1,240 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# chuskybrowser MCP server
|
|
2
|
+
|
|
3
|
+
A standalone [Model Context Protocol](https://modelcontextprotocol.io) server that exposes chuskybrowser to any MCP-compatible host — Claude Code, Cursor, etc. — without requiring OpenClaw.
|
|
4
|
+
|
|
5
|
+
It mirrors the existing OpenClaw plugin **1:1**: same 24 tool names, identical JSON-Schema parameters, and the same REST routes. Whether an agent reaches chusky via OpenClaw or MCP, the behavior is identical.
|
|
6
|
+
|
|
7
|
+
The initial MCP server implementation was contributed by [@epicsagas](https://github.com/epicsagas).
|
|
8
|
+
|
|
9
|
+
## Architecture
|
|
10
|
+
|
|
11
|
+
The MCP server is a thin stdio client over the chusky REST server. Two pieces:
|
|
12
|
+
|
|
13
|
+
- **REST server** (`server.js`) — launches Camoufox, exposes the HTTP API on `:9377`. Run **once**, it stays up.
|
|
14
|
+
- **MCP server** (`mcp/server.mjs`, exposed as the `chuskybrowser-mcp` bin) — translates MCP tool calls into REST calls. Claude Code spawns one **per session**.
|
|
15
|
+
|
|
16
|
+
Registering the MCP server does **not** require being inside the chuskybrowser checkout. The examples below work from any directory.
|
|
17
|
+
|
|
18
|
+
`mcp/` is also an **independently installable package** (`chuskybrowser-mcp`, its own `package.json`). It depends on nothing but `@modelcontextprotocol/sdk` — no `camoufox-js`, `playwright-core`, `express`, or the ~300MB browser binary download that the core server pulls in. Tool names, JSON-Schema parameters, REST routes, and response shaping are defined in `mcp/lib/tool-contracts.mjs`, which ships inside the standalone package. The OpenClaw plugin (`plugin.ts`) imports the same canonical module, so the two hosts cannot drift. See **Option D** below if you only want the MCP adapter (e.g. pointing `CHUSKY_BASE_URL` at a REST server running elsewhere).
|
|
19
|
+
|
|
20
|
+
## 1. Start the REST server
|
|
21
|
+
|
|
22
|
+
Clone the repo and start the server (one-time binary download on first run):
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
git clone https://github.com/zester4/chuskybrowser && cd chuskybrowser
|
|
26
|
+
npm install # downloads Camoufox (~300MB) on first run
|
|
27
|
+
npm start # → http://localhost:9377
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
This stays running in the background. It does not need to be your cwd afterwards.
|
|
31
|
+
|
|
32
|
+
## 2. Install the `chuskybrowser-mcp` bin
|
|
33
|
+
|
|
34
|
+
The MCP server is the same for every host — what differs is only the config file you paste into. First, make the `chuskybrowser-mcp` bin available on your PATH. Pick one:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# Option A — npm link (if you have the source checkout; picks up local edits)
|
|
38
|
+
cd chuskybrowser && npm link
|
|
39
|
+
|
|
40
|
+
# Option B — global install (no source checkout needed)
|
|
41
|
+
npm install -g chuskybrowser
|
|
42
|
+
|
|
43
|
+
# Option C — npx (no install; runs the published standalone adapter)
|
|
44
|
+
# Use this wherever a command is expected below.
|
|
45
|
+
npx -y chuskybrowser-mcp
|
|
46
|
+
|
|
47
|
+
# Option D — MCP adapter only, no core server deps (lightest footprint)
|
|
48
|
+
# Skips camoufox-js/playwright-core/express and the browser binary download —
|
|
49
|
+
# use this if a chuskybrowser REST server is already running (locally or
|
|
50
|
+
# remotely) and you only need the stdio adapter.
|
|
51
|
+
git clone https://github.com/zester4/chuskybrowser && cd chuskybrowser/mcp
|
|
52
|
+
npm install # installs only @modelcontextprotocol/sdk (~24MB, no postinstall)
|
|
53
|
+
npm link # chuskybrowser-mcp now resolves from any directory
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Verify the bin resolves from any directory:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
which chuskybrowser-mcp # → .../bin/chuskybrowser-mcp
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## 3. Register with your host
|
|
63
|
+
|
|
64
|
+
All five hosts speak standard MCP, so they all run the same `chuskybrowser-mcp` bin. Pick your host's config snippet below.
|
|
65
|
+
|
|
66
|
+
### Claude Code
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# CLI (user scope = available in every project)
|
|
70
|
+
claude mcp add chuskybrowser -s user -- chuskybrowser-mcp
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Or in `~/.claude.json` (user) / `.mcp.json` (project, checked in):
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"mcpServers": {
|
|
78
|
+
"chuskybrowser": {
|
|
79
|
+
"command": "chuskybrowser-mcp"
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Codex CLI
|
|
86
|
+
|
|
87
|
+
`~/.codex/config.toml` (user) or `.codex/config.toml` (project, trusted dirs only):
|
|
88
|
+
|
|
89
|
+
```toml
|
|
90
|
+
[mcp_servers.chuskybrowser]
|
|
91
|
+
command = "chuskybrowser-mcp"
|
|
92
|
+
env = { CHUSKY_BASE_URL = "http://localhost:9377" }
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Antigravity / agy
|
|
96
|
+
|
|
97
|
+
Global `~/.gemini/config/mcp_config.json` or workspace `.agents/mcp_config.json`:
|
|
98
|
+
|
|
99
|
+
```json
|
|
100
|
+
{
|
|
101
|
+
"mcpServers": {
|
|
102
|
+
"chuskybrowser": {
|
|
103
|
+
"command": "chuskybrowser-mcp"
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Cursor
|
|
110
|
+
|
|
111
|
+
Global `~/.cursor/mcp.json` or project `.cursor/mcp.json` (checked in):
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{
|
|
115
|
+
"mcpServers": {
|
|
116
|
+
"chuskybrowser": {
|
|
117
|
+
"command": "chuskybrowser-mcp"
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
(Or via UI: Settings → Cursor Settings → MCP → Add New MCP Server.)
|
|
124
|
+
|
|
125
|
+
### opencode
|
|
126
|
+
|
|
127
|
+
`opencode.json` in the project root, or global `~/.config/opencode/opencode.json`:
|
|
128
|
+
|
|
129
|
+
```json
|
|
130
|
+
{
|
|
131
|
+
"$schema": "https://opencode.ai/config.json",
|
|
132
|
+
"mcp": {
|
|
133
|
+
"chuskybrowser": {
|
|
134
|
+
"type": "local",
|
|
135
|
+
"command": ["chuskybrowser-mcp"]
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Common: env vars and from-source fallback
|
|
142
|
+
|
|
143
|
+
If you need cookie import or a non-default REST URL, add env. Example for Claude Code:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
claude mcp add chuskybrowser -s user \
|
|
147
|
+
--env CHUSKY_BASE_URL=http://localhost:9377 \
|
|
148
|
+
--env CHUSKY_API_KEY=<key> \
|
|
149
|
+
-- chuskybrowser-mcp
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
For the other hosts, add the same keys to the `env` / `environment` field of that host's snippet.
|
|
153
|
+
|
|
154
|
+
**From-source fallback** (only if you're inside the checkout and haven't linked the bin):
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
# Replace `chuskybrowser-mcp` with `node /absolute/path/to/mcp/server.mjs`
|
|
158
|
+
claude mcp add chuskybrowser -- node /Users/you/src/chuskybrowser/mcp/server.mjs
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
> ⚠️ Always prefer the `chuskybrowser-mcp` bin (options A/B/C/D above). The `node ./mcp/server.mjs` form is **path-dependent** — relative paths break outside the checkout.
|
|
162
|
+
|
|
163
|
+
## 4. Verify
|
|
164
|
+
|
|
165
|
+
| Host | How to verify |
|
|
166
|
+
|------|---------------|
|
|
167
|
+
| Claude Code | `/mcp` — `chuskybrowser` shows connected |
|
|
168
|
+
| Codex CLI | `codex` then check MCP server list |
|
|
169
|
+
| agy | `/mcp` overlay in the `agy` CLI |
|
|
170
|
+
| Cursor | Settings → MCP — server shows green |
|
|
171
|
+
| opencode | `opencode mcp list` |
|
|
172
|
+
|
|
173
|
+
You should see 24 tools: the 11 browser tools (`chusky_create_tab`, `chusky_snapshot`, `chusky_click`, `chusky_type`, `chusky_navigate`, `chusky_scroll`, `chusky_screenshot`, `chusky_evaluate`, `chusky_list_tabs`, `chusky_close_tab`, `chusky_import_cookies`), two handoff tools (`chusky_handoff_start`, `chusky_handoff_release`), three payment tools (`chusky_pay_arm`, `chusky_pay_status`, `chusky_pay_disarm`), and eight AgentCard tools (`agentcard_enrol`, `agentcard_cards`, `agentcard_placeholder`, `agentcard_buy`, `agentcard_merchants`, `agentcard_presets_list`, `agentcard_preset_set`, `agentcard_preset_attach`). The user id comes from `CHUSKY_USER_ID`, never from the model.
|
|
174
|
+
|
|
175
|
+
## Tools
|
|
176
|
+
|
|
177
|
+
| Tool | Purpose |
|
|
178
|
+
|------|---------|
|
|
179
|
+
| `chusky_create_tab` | Open a URL → returns `tabId` |
|
|
180
|
+
| `chusky_snapshot` | Accessibility snapshot + element refs (`e1`, `e2`, ...) + screenshot |
|
|
181
|
+
| `chusky_navigate` | Go to a URL **or** use a search macro (`@google_search`, `@reddit_search`, ...) |
|
|
182
|
+
| `chusky_click` | Click by element ref (`e1`) or CSS selector |
|
|
183
|
+
| `chusky_type` | Type text into a ref/selector, optional `pressEnter` |
|
|
184
|
+
| `chusky_scroll` | Scroll by pixels (unreliable on lazy-load pages — prefer `chusky_evaluate`) |
|
|
185
|
+
| `chusky_screenshot` | Standalone screenshot |
|
|
186
|
+
| `chusky_evaluate` | Run JS in page context — extract data, call page APIs, scroll via `window.scrollTo` |
|
|
187
|
+
| `chusky_list_tabs` | List open tabs in this session |
|
|
188
|
+
| `chusky_close_tab` | Close a tab |
|
|
189
|
+
| `chusky_import_cookies` | Import a Netscape cookie file (needs `CHUSKY_API_KEY`) |
|
|
190
|
+
|
|
191
|
+
## Workflow
|
|
192
|
+
|
|
193
|
+
Every interaction follows the same shape — **snapshot before you act**:
|
|
194
|
+
|
|
195
|
+
1. `create_tab({ url })` → `tabId`
|
|
196
|
+
2. `snapshot({ tabId })` → element refs (`e1`, `e2`, ...)
|
|
197
|
+
3. `click`/`type` using those refs
|
|
198
|
+
4. `snapshot` again to read the new state
|
|
199
|
+
5. `close_tab` when done
|
|
200
|
+
|
|
201
|
+
Element refs are unambiguous and preferred over CSS selectors — a selector that matches multiple elements returns `422 strict mode violation`, in which case re-snapshot and click by ref.
|
|
202
|
+
|
|
203
|
+
## Environment variables
|
|
204
|
+
|
|
205
|
+
| Var | Default | Purpose |
|
|
206
|
+
|-----|---------|---------|
|
|
207
|
+
| `CHUSKY_BASE_URL` | `http://localhost:9377` | REST server URL |
|
|
208
|
+
| `CHUSKY_USER_ID` | `mcp-<random>` | Session isolation (one MCP server = one chusky session) |
|
|
209
|
+
| `CHUSKY_SESSION_KEY` | `default` | Tab partition within the user |
|
|
210
|
+
| `CHUSKY_ACCESS_KEY` | _(unset)_ | Global bearer token gating the REST server (superkey). If set, forwarded as `Authorization: Bearer <key>` on **every** tool call automatically — must match the REST server's own `CHUSKY_ACCESS_KEY`. Without this, a globally-authenticated REST server rejects all tool calls except cookie import. |
|
|
211
|
+
| `CHUSKY_API_KEY` | _(unset)_ | Self-chosen secret gating cookie import. Optional on localhost; required on remote/production and must match the REST server's `CHUSKY_API_KEY`. Only affects `chusky_import_cookies`. |
|
|
212
|
+
|
|
213
|
+
## Troubleshooting
|
|
214
|
+
|
|
215
|
+
- **`503 session_expired` / `tab create timed out`** — the REST server's browser session died (often after a prior failed call destabilized it). Restart `npm start`.
|
|
216
|
+
- **`chusky_scroll` returns `{ok:true}` but the page doesn't move** — expected on lazy-load / virtual-scroll pages; the server's `mouse.wheel` no-ops there. Use `chusky_evaluate` with `window.scrollTo` / `scrollBy`.
|
|
217
|
+
- **`422 strict mode violation ... resolved to N elements`** on `click` — CSS selector matched multiple elements. Re-snapshot and click by element ref.
|
|
218
|
+
- **`403 Forbidden` on `chusky_import_cookies`** — key mismatch between REST server and MCP server, or hitting a remote server without `CHUSKY_API_KEY`.
|
|
219
|
+
- **`401`/`403` on every tool call, not just cookie import** — the REST server has `CHUSKY_ACCESS_KEY` set. Set the same `CHUSKY_ACCESS_KEY` in the MCP server's env (see table above) — it's forwarded automatically as `Authorization: Bearer` once set.
|
|
220
|
+
|
|
221
|
+
## Testing (developer)
|
|
222
|
+
|
|
223
|
+
Two layers, covering different things:
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
# 1. `test:mcp` installs the standalone adapter's locked dependencies, then
|
|
227
|
+
# runs the in-repository and packed-tarball handshake tests. Neither needs
|
|
228
|
+
# a REST server.
|
|
229
|
+
npm run test:mcp
|
|
230
|
+
|
|
231
|
+
# 2. Mock-HTTP contract tests — verifies the actual REST traffic each tool
|
|
232
|
+
# produces: routes, methods, request bodies, CHUSKY_ACCESS_KEY/CHUSKY_API_KEY
|
|
233
|
+
# auth headers, cookie parsing (Netscape file → `{ cookies }` body, never a
|
|
234
|
+
# path), screenshot decoding, and error handling. Mocked REST server — no
|
|
235
|
+
# live chuskybrowser process needed. This is what makes the "OpenClaw and
|
|
236
|
+
# MCP send identical requests" claim verifiable, not just asserted.
|
|
237
|
+
NODE_OPTIONS='--experimental-vm-modules' npx jest tests/unit/mcp-contracts.test.js
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
The packed-tarball check installs the generated `chuskybrowser-mcp` tarball in an empty directory before its handshake. It catches imports that reach outside the standalone package.
|
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Load only the environment settings the standalone MCP adapter requires.
|
|
6
|
+
* This module intentionally does not import the core server configuration so
|
|
7
|
+
* chuskybrowser-mcp can run without the core package installed.
|
|
8
|
+
*/
|
|
9
|
+
export function loadMcpConfig() {
|
|
10
|
+
return {
|
|
11
|
+
port: parseInt(process.env.CHUSKY_PORT || process.env.PORT || '9377', 10),
|
|
12
|
+
apiKey: process.env.CHUSKY_API_KEY || '',
|
|
13
|
+
accessKey: (process.env.CHUSKY_ACCESS_KEY || '').trim(),
|
|
14
|
+
cookiesDir: process.env.CHUSKY_COOKIES_DIR || join(os.homedir(), '.chusky', 'cookies'),
|
|
15
|
+
};
|
|
16
|
+
}
|
package/lib/cookies.mjs
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cookie file reading and parsing for chuskybrowser.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from 'fs/promises';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse a Netscape-format cookie file into structured cookie objects.
|
|
10
|
+
* @param {string} text - Raw cookie file content
|
|
11
|
+
* @returns {Array<{name: string, value: string, domain: string, path: string, expires: number, httpOnly?: boolean, secure?: boolean}>}
|
|
12
|
+
*/
|
|
13
|
+
function parseNetscapeCookieFile(text) {
|
|
14
|
+
const cookies = [];
|
|
15
|
+
const cleaned = text.replace(/^\uFEFF/, '');
|
|
16
|
+
|
|
17
|
+
for (const rawLine of cleaned.split(/\r?\n/)) {
|
|
18
|
+
const line = rawLine.trim();
|
|
19
|
+
if (!line) continue;
|
|
20
|
+
if (line.startsWith('#') && !line.startsWith('#HttpOnly_')) continue;
|
|
21
|
+
|
|
22
|
+
let httpOnly = false;
|
|
23
|
+
let working = line;
|
|
24
|
+
if (working.startsWith('#HttpOnly_')) {
|
|
25
|
+
httpOnly = true;
|
|
26
|
+
working = working.replace(/^#HttpOnly_/, '');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const parts = working.split('\t');
|
|
30
|
+
if (parts.length < 7) continue;
|
|
31
|
+
|
|
32
|
+
const domain = parts[0];
|
|
33
|
+
const cookiePath = parts[2];
|
|
34
|
+
const secure = parts[3].toUpperCase() === 'TRUE';
|
|
35
|
+
// Netscape cookie files use 0 (and, in the wild, occasionally a
|
|
36
|
+
// non-numeric placeholder) to mean "session cookie". Playwright's
|
|
37
|
+
// addCookies() spells that -1 instead and reads 0 as a Unix timestamp,
|
|
38
|
+
// i.e. 1970-01-01, so the cookie is already expired the moment it is
|
|
39
|
+
// added and is dropped without a word. Map anything that is not a
|
|
40
|
+
// positive number onto -1.
|
|
41
|
+
const rawExpires = Number(parts[4]);
|
|
42
|
+
const expires = Number.isFinite(rawExpires) && rawExpires > 0 ? rawExpires : -1;
|
|
43
|
+
const name = parts[5];
|
|
44
|
+
const value = parts.slice(6).join('\t');
|
|
45
|
+
|
|
46
|
+
cookies.push({ name, value, domain, path: cookiePath, expires, httpOnly, secure });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return cookies;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Read and parse cookies from a Netscape cookie file.
|
|
54
|
+
* @param {object} opts
|
|
55
|
+
* @param {string} opts.cookiesDir - Base directory for cookie files
|
|
56
|
+
* @param {string} opts.cookiesPath - Relative path to the cookie file within cookiesDir
|
|
57
|
+
* @param {string} [opts.domainSuffix] - Only include cookies whose domain ends with this suffix
|
|
58
|
+
* @param {number} [opts.maxBytes=5242880] - Maximum file size in bytes
|
|
59
|
+
* @returns {Promise<Array<{name: string, value: string, domain: string, path: string, expires: number, httpOnly: boolean, secure: boolean}>>}
|
|
60
|
+
*/
|
|
61
|
+
function isPathInside(basePath, targetPath) {
|
|
62
|
+
const relative = path.relative(basePath, targetPath);
|
|
63
|
+
return relative !== ''
|
|
64
|
+
&& relative !== '..'
|
|
65
|
+
&& !relative.startsWith(`..${path.sep}`)
|
|
66
|
+
&& !path.isAbsolute(relative);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function readCookieFile({ cookiesDir, cookiesPath, domainSuffix, maxBytes = 5 * 1024 * 1024 }) {
|
|
70
|
+
if (typeof cookiesPath !== 'string' || cookiesPath.length === 0) {
|
|
71
|
+
throw new Error('cookiesPath must be a non-empty string');
|
|
72
|
+
}
|
|
73
|
+
if (path.isAbsolute(cookiesPath)) {
|
|
74
|
+
throw new Error('cookiesPath must be a relative path within the cookies directory');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const realCookiesDir = await fs.realpath(cookiesDir);
|
|
78
|
+
const requestedPath = path.resolve(realCookiesDir, cookiesPath);
|
|
79
|
+
if (!isPathInside(realCookiesDir, requestedPath)) {
|
|
80
|
+
throw new Error('cookiesPath must be a relative path within the cookies directory');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const realCookiePath = await fs.realpath(requestedPath);
|
|
84
|
+
if (!isPathInside(realCookiesDir, realCookiePath)) {
|
|
85
|
+
throw new Error('cookiesPath resolves outside the cookies directory');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const stat = await fs.stat(realCookiePath);
|
|
89
|
+
if (stat.size > maxBytes) {
|
|
90
|
+
throw new Error('Cookie file too large (max 5MB)');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const text = await fs.readFile(realCookiePath, 'utf8');
|
|
94
|
+
let cookies = parseNetscapeCookieFile(text);
|
|
95
|
+
if (domainSuffix) {
|
|
96
|
+
cookies = cookies.filter((c) => c.domain.endsWith(domainSuffix));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return cookies.map((c) => ({
|
|
100
|
+
name: c.name,
|
|
101
|
+
value: c.value,
|
|
102
|
+
domain: c.domain,
|
|
103
|
+
path: c.path,
|
|
104
|
+
expires: c.expires,
|
|
105
|
+
httpOnly: !!c.httpOnly,
|
|
106
|
+
secure: !!c.secure,
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Import all cookies from the default bootstrap cookie file into a Playwright context.
|
|
112
|
+
* Intended for first-run session seeding before any persistent storage state exists.
|
|
113
|
+
* Missing file is treated as a no-op.
|
|
114
|
+
* @param {object} opts
|
|
115
|
+
* @param {string} opts.cookiesDir - Base directory for cookie files
|
|
116
|
+
* @param {object} opts.context - Playwright BrowserContext
|
|
117
|
+
* @param {string} [opts.cookiesPath='cookies.txt'] - Relative cookie file path within cookiesDir
|
|
118
|
+
* @param {object} [opts.logger=console] - Logger with warn()
|
|
119
|
+
* @returns {Promise<{imported: number, source: string|null}>}
|
|
120
|
+
*/
|
|
121
|
+
async function importBootstrapCookies({ cookiesDir, context, cookiesPath = 'cookies.txt', logger = console }) {
|
|
122
|
+
if (!cookiesDir || !context) {
|
|
123
|
+
return { imported: 0, source: null };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const resolved = path.resolve(cookiesDir, cookiesPath);
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const cookies = await readCookieFile({ cookiesDir, cookiesPath });
|
|
130
|
+
if (cookies.length === 0) {
|
|
131
|
+
return { imported: 0, source: resolved };
|
|
132
|
+
}
|
|
133
|
+
await context.addCookies(cookies);
|
|
134
|
+
return { imported: cookies.length, source: resolved };
|
|
135
|
+
} catch (err) {
|
|
136
|
+
if (err?.code === 'ENOENT') {
|
|
137
|
+
return { imported: 0, source: null };
|
|
138
|
+
}
|
|
139
|
+
logger?.warn?.('failed to import bootstrap cookies', {
|
|
140
|
+
cookiesPath: resolved,
|
|
141
|
+
error: err?.message || String(err),
|
|
142
|
+
});
|
|
143
|
+
return { imported: 0, source: resolved };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export { parseNetscapeCookieFile, readCookieFile, importBootstrapCookies };
|
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical tool contracts for the chuskybrowser REST API.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth shared by two hosts that expose the same 11 tools:
|
|
5
|
+
* - mcp/server.mjs (stdio MCP server for Claude Code, Codex, agy, Cursor, opencode)
|
|
6
|
+
* - plugin.ts (OpenClaw plugin)
|
|
7
|
+
*
|
|
8
|
+
* Importing this module from both hosts means tool names, JSON-Schema
|
|
9
|
+
* parameters, REST routes, request bodies, auth semantics, and response
|
|
10
|
+
* shaping cannot drift — the REST server sees identical traffic regardless of
|
|
11
|
+
* which host the agent reached it through.
|
|
12
|
+
*
|
|
13
|
+
* Auth model (mirrors lib/auth.js):
|
|
14
|
+
* - CHUSKY_ACCESS_KEY (global superkey): when set, every route except
|
|
15
|
+
* /health, cookie import, /auth-sessions, /stop requires
|
|
16
|
+
* `Authorization: Bearer <accessKey>`.
|
|
17
|
+
* - CHUSKY_API_KEY (cookie-only gate): the cookie-import route requires
|
|
18
|
+
* `Authorization: Bearer <apiKey>` (or loopback + non-production).
|
|
19
|
+
*
|
|
20
|
+
* Each buildRequest() result declares `auth: 'accessKey' | 'apiKey' | 'none'`
|
|
21
|
+
* and `responseKind: 'json' | 'snapshot' | 'image'` so the host's fetch layer
|
|
22
|
+
* and response adapter stay declarative.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { readCookieFile } from './cookies.mjs';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {Object} ToolDef
|
|
29
|
+
* @property {string} name
|
|
30
|
+
* @property {string} description
|
|
31
|
+
* @property {object} inputSchema - JSON Schema (object) for tool arguments.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @typedef {Object} RequestSpec
|
|
36
|
+
* @property {string} method - HTTP method.
|
|
37
|
+
* @property {string} path - Path (already includes query string when needed).
|
|
38
|
+
* @property {'accessKey'|'apiKey'|'none'} auth
|
|
39
|
+
* @property {'json'|'snapshot'|'image'} responseKind
|
|
40
|
+
* @property {object} [body] - JSON body.
|
|
41
|
+
* @property {object} [meta] - Extra metadata for response shaping.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @typedef {Object} CallContext
|
|
46
|
+
* @property {string} userId - Session owner (scopes cookie/storage partition).
|
|
47
|
+
* @property {string} [sessionKey] - Tab partition within a user.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
const SEARCH_MACROS = [
|
|
51
|
+
'@google_search',
|
|
52
|
+
'@youtube_search',
|
|
53
|
+
'@amazon_search',
|
|
54
|
+
'@reddit_search',
|
|
55
|
+
'@wikipedia_search',
|
|
56
|
+
'@twitter_search',
|
|
57
|
+
'@yelp_search',
|
|
58
|
+
'@spotify_search',
|
|
59
|
+
'@netflix_search',
|
|
60
|
+
'@linkedin_search',
|
|
61
|
+
'@instagram_search',
|
|
62
|
+
'@tiktok_search',
|
|
63
|
+
'@twitch_search',
|
|
64
|
+
'@bestbuy_search',
|
|
65
|
+
'@indeed_search',
|
|
66
|
+
'@booking_search',
|
|
67
|
+
'@pubmed_search',
|
|
68
|
+
'@zillow_search',
|
|
69
|
+
'@npm_search',
|
|
70
|
+
'@dockerhub_search',
|
|
71
|
+
'@nhs_search',
|
|
72
|
+
'@legislation_search',
|
|
73
|
+
'@dailymed_search',
|
|
74
|
+
'@huggingface_search',
|
|
75
|
+
'@jsr_search',
|
|
76
|
+
'@bing_news_search',
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The tools, identical schema for both hosts. Edit here and both update.
|
|
81
|
+
* @type {ToolDef[]}
|
|
82
|
+
*/
|
|
83
|
+
export const TOOL_DEFS = [
|
|
84
|
+
{
|
|
85
|
+
name: 'chusky_create_tab',
|
|
86
|
+
description:
|
|
87
|
+
'PREFERRED: Create a new browser tab using Camoufox anti-detection browser. Use chusky tools instead of Chrome/built-in browser - they bypass bot detection on Google, Amazon, LinkedIn, etc. Returns tabId for subsequent operations.',
|
|
88
|
+
inputSchema: {
|
|
89
|
+
type: 'object',
|
|
90
|
+
properties: {
|
|
91
|
+
url: { type: 'string', description: 'Initial URL to navigate to' },
|
|
92
|
+
},
|
|
93
|
+
required: ['url'],
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'chusky_snapshot',
|
|
98
|
+
description:
|
|
99
|
+
'Get accessibility snapshot of a Camoufox page with element refs (e1, e2, etc.) for interaction, plus a visual screenshot. ' +
|
|
100
|
+
'Large pages are truncated with pagination links preserved at the bottom. ' +
|
|
101
|
+
'If the response includes hasMore=true and nextOffset, call again with that offset to see more content.',
|
|
102
|
+
inputSchema: {
|
|
103
|
+
type: 'object',
|
|
104
|
+
properties: {
|
|
105
|
+
tabId: { type: 'string', description: 'Tab identifier' },
|
|
106
|
+
offset: {
|
|
107
|
+
type: 'number',
|
|
108
|
+
description: 'Character offset for paginated snapshots. Use nextOffset from a previous truncated response.',
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
required: ['tabId'],
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: 'chusky_click',
|
|
116
|
+
description: 'Click an element in a Camoufox tab by ref (e.g., e1) or CSS selector.',
|
|
117
|
+
inputSchema: {
|
|
118
|
+
type: 'object',
|
|
119
|
+
properties: {
|
|
120
|
+
tabId: { type: 'string', description: 'Tab identifier' },
|
|
121
|
+
ref: { type: 'string', description: 'Element ref from snapshot (e.g., e1)' },
|
|
122
|
+
selector: { type: 'string', description: 'CSS selector (alternative to ref)' },
|
|
123
|
+
},
|
|
124
|
+
required: ['tabId'],
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: 'chusky_type',
|
|
129
|
+
description: 'Type text into an element in a Camoufox tab.',
|
|
130
|
+
inputSchema: {
|
|
131
|
+
type: 'object',
|
|
132
|
+
properties: {
|
|
133
|
+
tabId: { type: 'string', description: 'Tab identifier' },
|
|
134
|
+
ref: { type: 'string', description: 'Element ref from snapshot (e.g., e2)' },
|
|
135
|
+
selector: { type: 'string', description: 'CSS selector (alternative to ref)' },
|
|
136
|
+
text: { type: 'string', description: 'Text to type' },
|
|
137
|
+
pressEnter: { type: 'boolean', description: 'Press Enter after typing' },
|
|
138
|
+
},
|
|
139
|
+
required: ['tabId', 'text'],
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: 'chusky_navigate',
|
|
144
|
+
description:
|
|
145
|
+
'Navigate a Camoufox tab to a URL or use a search macro (@google_search, @youtube_search, etc.). Preferred over Chrome for sites with bot detection.',
|
|
146
|
+
inputSchema: {
|
|
147
|
+
type: 'object',
|
|
148
|
+
properties: {
|
|
149
|
+
tabId: { type: 'string', description: 'Tab identifier' },
|
|
150
|
+
url: { type: 'string', description: 'URL to navigate to' },
|
|
151
|
+
macro: {
|
|
152
|
+
type: 'string',
|
|
153
|
+
description: 'Search macro (e.g., @google_search, @youtube_search)',
|
|
154
|
+
enum: SEARCH_MACROS,
|
|
155
|
+
},
|
|
156
|
+
query: { type: 'string', description: 'Search query (when using macro)' },
|
|
157
|
+
},
|
|
158
|
+
required: ['tabId'],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: 'chusky_scroll',
|
|
163
|
+
description: 'Scroll a Camoufox page.',
|
|
164
|
+
inputSchema: {
|
|
165
|
+
type: 'object',
|
|
166
|
+
properties: {
|
|
167
|
+
tabId: { type: 'string', description: 'Tab identifier' },
|
|
168
|
+
direction: { type: 'string', enum: ['up', 'down', 'left', 'right'] },
|
|
169
|
+
amount: { type: 'number', description: 'Pixels to scroll' },
|
|
170
|
+
},
|
|
171
|
+
required: ['tabId', 'direction'],
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: 'chusky_screenshot',
|
|
176
|
+
description: 'Take a screenshot of a Camoufox page.',
|
|
177
|
+
inputSchema: {
|
|
178
|
+
type: 'object',
|
|
179
|
+
properties: {
|
|
180
|
+
tabId: { type: 'string', description: 'Tab identifier' },
|
|
181
|
+
},
|
|
182
|
+
required: ['tabId'],
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
name: 'chusky_close_tab',
|
|
187
|
+
description: 'Close a Camoufox browser tab.',
|
|
188
|
+
inputSchema: {
|
|
189
|
+
type: 'object',
|
|
190
|
+
properties: {
|
|
191
|
+
tabId: { type: 'string', description: 'Tab identifier' },
|
|
192
|
+
},
|
|
193
|
+
required: ['tabId'],
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
name: 'chusky_evaluate',
|
|
198
|
+
description:
|
|
199
|
+
"Execute JavaScript in a Camoufox tab's page context. Returns the result of the expression. Use for injecting scripts, reading page state, or calling web app APIs.",
|
|
200
|
+
inputSchema: {
|
|
201
|
+
type: 'object',
|
|
202
|
+
properties: {
|
|
203
|
+
tabId: { type: 'string', description: 'Tab identifier' },
|
|
204
|
+
expression: {
|
|
205
|
+
type: 'string',
|
|
206
|
+
description: 'JavaScript expression to evaluate in the page context',
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
required: ['tabId', 'expression'],
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
name: 'chusky_list_tabs',
|
|
214
|
+
description: 'List all open Chusky tabs for the current session.',
|
|
215
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
name: 'chusky_import_cookies',
|
|
219
|
+
description:
|
|
220
|
+
'Import cookies into the current Camoufox session (Netscape cookie file). Use to authenticate to sites like LinkedIn without interactive login. Requires CHUSKY_API_KEY on the REST server.',
|
|
221
|
+
inputSchema: {
|
|
222
|
+
type: 'object',
|
|
223
|
+
properties: {
|
|
224
|
+
cookiesPath: {
|
|
225
|
+
type: 'string',
|
|
226
|
+
description: 'Relative path to a Netscape-format cookies.txt file within the server cookies directory',
|
|
227
|
+
},
|
|
228
|
+
domainSuffix: {
|
|
229
|
+
type: 'string',
|
|
230
|
+
description: 'Only import cookies whose domain ends with this suffix',
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
required: ['cookiesPath'],
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
name: 'chusky_handoff_start',
|
|
238
|
+
description:
|
|
239
|
+
'Hand a tab to the human for a login, a CAPTCHA or a check. Returns a controlUrl: send it to the user only, unchanged. The agent must not act on the tab until the handoff is released.',
|
|
240
|
+
inputSchema: {
|
|
241
|
+
type: 'object',
|
|
242
|
+
properties: {
|
|
243
|
+
tabId: { type: 'string', description: 'Tab identifier' },
|
|
244
|
+
reason: { type: 'string', description: 'Shown to the user, e.g. "Sign in to your bank"' },
|
|
245
|
+
idleMs: { type: 'number', description: 'Milliseconds of inactivity before control returns to the agent' },
|
|
246
|
+
},
|
|
247
|
+
required: ['tabId'],
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
name: 'chusky_handoff_release',
|
|
252
|
+
description: 'Return control of a tab to the agent after a handoff.',
|
|
253
|
+
inputSchema: {
|
|
254
|
+
type: 'object',
|
|
255
|
+
properties: { tabId: { type: 'string', description: 'Tab identifier' } },
|
|
256
|
+
required: ['tabId'],
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
name: 'chusky_pay_arm',
|
|
261
|
+
description:
|
|
262
|
+
'Pay on this tab with the user\'s own card. Arms payment on the page: when the checkout sends its card request, it is paused until the user approves on their device with their passkey. The user never gives the card number, and the agent never sees it. Returns attachmentId; then call chusky_pay_status to get the approval link to send to the user.',
|
|
263
|
+
inputSchema: {
|
|
264
|
+
type: 'object',
|
|
265
|
+
properties: {
|
|
266
|
+
tabId: { type: 'string', description: 'Tab identifier of the checkout page' },
|
|
267
|
+
merchant: { type: 'string', description: 'Store name, shown on the approval screen (max 120 characters)' },
|
|
268
|
+
amount: { type: 'integer', description: 'Most the user can be charged, in minor units (cents). 2306 is $23.06' },
|
|
269
|
+
currency: { type: 'string', description: 'Lowercase ISO 4217 code, e.g. usd' },
|
|
270
|
+
checkoutOrigin: { type: 'string', description: 'https origin of the checkout page, e.g. https://shop.example.com' },
|
|
271
|
+
cardId: { type: 'string', description: 'Which stored card to use; omit for the first one' },
|
|
272
|
+
},
|
|
273
|
+
required: ['tabId', 'merchant', 'amount', 'currency'],
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
name: 'chusky_pay_status',
|
|
278
|
+
description:
|
|
279
|
+
'Read a payment attachment. approvalUrl appears once a card request is paused: send it to the user unchanged. phase is idle, pending or completed.',
|
|
280
|
+
inputSchema: {
|
|
281
|
+
type: 'object',
|
|
282
|
+
properties: {
|
|
283
|
+
tabId: { type: 'string', description: 'Tab identifier' },
|
|
284
|
+
attachmentId: { type: 'string', description: 'From chusky_pay_arm' },
|
|
285
|
+
},
|
|
286
|
+
required: ['tabId', 'attachmentId'],
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
name: 'chusky_pay_disarm',
|
|
291
|
+
description: 'Stop paying on a tab. Any paused card request that is not yet approved is cancelled.',
|
|
292
|
+
inputSchema: {
|
|
293
|
+
type: 'object',
|
|
294
|
+
properties: {
|
|
295
|
+
tabId: { type: 'string', description: 'Tab identifier' },
|
|
296
|
+
attachmentId: { type: 'string', description: 'From chusky_pay_arm' },
|
|
297
|
+
},
|
|
298
|
+
required: ['tabId', 'attachmentId'],
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
name: 'agentcard_enrol',
|
|
303
|
+
description:
|
|
304
|
+
'Start storing the user\'s card with AgentCard. Returns has_card when a card is already stored (nothing to do), otherwise a vault link. Send the link to the user unchanged. The user enters the card on AgentCard\'s page. Card numbers never pass through this tool.',
|
|
305
|
+
inputSchema: {
|
|
306
|
+
type: 'object',
|
|
307
|
+
properties: {
|
|
308
|
+
addAnother: { type: 'boolean', description: 'true to add a second card for this user' },
|
|
309
|
+
},
|
|
310
|
+
required: [],
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
name: 'agentcard_cards',
|
|
315
|
+
description: 'List the cards stored for this user (brand, last4, expiry). Card numbers are never returned.',
|
|
316
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
name: 'agentcard_buy',
|
|
320
|
+
description:
|
|
321
|
+
'Shop for the user at real stores through AgentCard: products, food delivery, table reservations (action reserve) and flights (action book). Pass the shopper\'s words in ask. Pass product_id or store_id only when the user picked one from a previous result. Set confirm true only when the user said yes to the cart. The response has links for the user: send them exactly as given, never change or summarise them.',
|
|
322
|
+
inputSchema: {
|
|
323
|
+
type: 'object',
|
|
324
|
+
properties: {
|
|
325
|
+
ask: { type: 'string', description: 'What the user said, in their own words (max 2000 characters)' },
|
|
326
|
+
threadId: { type: 'string', description: 'The chat, so one purchase stays in one chat (max 128 characters)' },
|
|
327
|
+
confirm: { type: 'boolean', description: 'true only when the user said yes to the cart or asks to check out' },
|
|
328
|
+
new_order: { type: 'boolean', description: 'true only when the user drops the cart to start a new order' },
|
|
329
|
+
product_id: { type: 'string', description: 'The product the user picked from the last result' },
|
|
330
|
+
store_id: { type: 'string', description: 'The store the user picked from the last result' },
|
|
331
|
+
},
|
|
332
|
+
required: ['ask'],
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
name: 'agentcard_merchants',
|
|
337
|
+
description:
|
|
338
|
+
'Check whether AgentCard can pay at a store before sending the user there. supported is true (a real purchase went through), false (cannot pay there) or null (not known yet). For direct merchants such as DoorDash, link_status shows whether the user still has to connect.',
|
|
339
|
+
inputSchema: {
|
|
340
|
+
type: 'object',
|
|
341
|
+
properties: {
|
|
342
|
+
kind: { type: 'string', description: 'e.g. catalog or direct' },
|
|
343
|
+
q: { type: 'string', description: 'Part of a store name or domain (max 100 characters)' },
|
|
344
|
+
limit: { type: 'integer', description: '1 to 100' },
|
|
345
|
+
cursor: { type: 'string', description: 'next_cursor from the previous page' },
|
|
346
|
+
},
|
|
347
|
+
required: [],
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
name: 'agentcard_placeholder',
|
|
352
|
+
description:
|
|
353
|
+
'The test card to type into a checkout for one of this user\'s stored cards: the card number, security code and expiry that match the card that pays. The real card number is never returned. Use the same cardId as chusky_pay_arm.',
|
|
354
|
+
inputSchema: {
|
|
355
|
+
type: 'object',
|
|
356
|
+
properties: {
|
|
357
|
+
cardId: { type: 'string', description: 'Which stored card; omit for the first one. Pass the same cardId to chusky_pay_arm.' },
|
|
358
|
+
},
|
|
359
|
+
required: [],
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
name: 'agentcard_presets_list',
|
|
364
|
+
description: 'List this user\'s spending rules (presets). Limits are enforced by AgentCard before any approval.',
|
|
365
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
name: 'agentcard_preset_set',
|
|
369
|
+
description:
|
|
370
|
+
'Create or change one of the user\'s spending rules. Money is in cents. Rules apply to the next purchase. Use this when the user sets a limit, such as a daily cap or merchants they allow.',
|
|
371
|
+
inputSchema: {
|
|
372
|
+
type: 'object',
|
|
373
|
+
properties: {
|
|
374
|
+
label: { type: 'string', description: '1 to 40 lowercase letters, digits or hyphens, e.g. groceries-weekly' },
|
|
375
|
+
rules: {
|
|
376
|
+
type: 'object',
|
|
377
|
+
description: 'At least one rule. Money fields are integer cents.',
|
|
378
|
+
properties: {
|
|
379
|
+
perDayCents: { type: 'integer' },
|
|
380
|
+
perWeekCents: { type: 'integer' },
|
|
381
|
+
perMonthCents: { type: 'integer' },
|
|
382
|
+
totalCents: { type: 'integer' },
|
|
383
|
+
onlyMerchants: { type: 'array', items: { type: 'string' } },
|
|
384
|
+
categories: { type: 'array', items: { type: 'string', enum: ['meals', 'groceries', 'travel', 'software', 'ai', 'wellness', 'retail'] } },
|
|
385
|
+
currencies: { type: 'array', items: { type: 'string' } },
|
|
386
|
+
onlyDays: { type: 'string' },
|
|
387
|
+
onlyHours: { type: 'string', description: 'e.g. 9-17' },
|
|
388
|
+
timezone: { type: 'string', description: 'IANA zone, e.g. Europe/London' },
|
|
389
|
+
mode: { type: 'string', enum: ['strict', 'watch'] },
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
required: ['label', 'rules'],
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
name: 'agentcard_preset_attach',
|
|
398
|
+
description: 'Attach one of the user\'s spending rules to one of their cards (default: the first card).',
|
|
399
|
+
inputSchema: {
|
|
400
|
+
type: 'object',
|
|
401
|
+
properties: {
|
|
402
|
+
label: { type: 'string', description: 'The rule\'s label' },
|
|
403
|
+
cardId: { type: 'string', description: 'Which stored card' },
|
|
404
|
+
},
|
|
405
|
+
required: ['label'],
|
|
406
|
+
},
|
|
407
|
+
},
|
|
408
|
+
];
|
|
409
|
+
|
|
410
|
+
/** Quick name → def lookup. */
|
|
411
|
+
export const TOOL_BY_NAME = Object.fromEntries(TOOL_DEFS.map((t) => [t.name, t]));
|
|
412
|
+
|
|
413
|
+
/** Tool names in canonical order. */
|
|
414
|
+
export const TOOL_NAMES = TOOL_DEFS.map((t) => t.name);
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Strip the routing key (tabId) from args, returning the REST body fields.
|
|
418
|
+
* @param {Record<string, unknown>} args
|
|
419
|
+
* @param {string} [dropKey]
|
|
420
|
+
* @returns {Record<string, unknown>}
|
|
421
|
+
*/
|
|
422
|
+
function without(args, dropKey = 'tabId') {
|
|
423
|
+
const { [dropKey]: _omit, ...rest } = args;
|
|
424
|
+
return rest;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Build a REST request spec for a tool. Pure / synchronous — except cookie
|
|
429
|
+
* import, which needs async file parsing; use buildCookieRequest() for that.
|
|
430
|
+
*
|
|
431
|
+
* @param {string} name - Tool name.
|
|
432
|
+
* @param {Record<string, unknown>} args - Tool arguments.
|
|
433
|
+
* @param {CallContext} ctx - { userId, sessionKey }.
|
|
434
|
+
* @returns {RequestSpec}
|
|
435
|
+
* @throws {Error} if the tool is unknown.
|
|
436
|
+
*/
|
|
437
|
+
export function buildRequest(name, args, ctx) {
|
|
438
|
+
const userId = ctx.userId;
|
|
439
|
+
const sessionKey = ctx.sessionKey;
|
|
440
|
+
switch (name) {
|
|
441
|
+
case 'chusky_create_tab':
|
|
442
|
+
return {
|
|
443
|
+
method: 'POST',
|
|
444
|
+
path: '/tabs',
|
|
445
|
+
auth: 'accessKey',
|
|
446
|
+
responseKind: 'json',
|
|
447
|
+
body: { url: args.url, userId, sessionKey },
|
|
448
|
+
};
|
|
449
|
+
case 'chusky_snapshot': {
|
|
450
|
+
const params = new URLSearchParams({ userId, includeScreenshot: 'true' });
|
|
451
|
+
if (args.offset != null && args.offset !== '') params.set('offset', String(args.offset));
|
|
452
|
+
return {
|
|
453
|
+
method: 'GET',
|
|
454
|
+
path: `/tabs/${args.tabId}/snapshot?${params}`,
|
|
455
|
+
auth: 'accessKey',
|
|
456
|
+
responseKind: 'snapshot',
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
case 'chusky_click':
|
|
460
|
+
return {
|
|
461
|
+
method: 'POST',
|
|
462
|
+
path: `/tabs/${args.tabId}/click`,
|
|
463
|
+
auth: 'accessKey',
|
|
464
|
+
responseKind: 'json',
|
|
465
|
+
body: { ...without(args), userId },
|
|
466
|
+
};
|
|
467
|
+
case 'chusky_type':
|
|
468
|
+
return {
|
|
469
|
+
method: 'POST',
|
|
470
|
+
path: `/tabs/${args.tabId}/type`,
|
|
471
|
+
auth: 'accessKey',
|
|
472
|
+
responseKind: 'json',
|
|
473
|
+
body: { ...without(args), userId },
|
|
474
|
+
};
|
|
475
|
+
case 'chusky_navigate':
|
|
476
|
+
return {
|
|
477
|
+
method: 'POST',
|
|
478
|
+
path: `/tabs/${args.tabId}/navigate`,
|
|
479
|
+
auth: 'accessKey',
|
|
480
|
+
responseKind: 'json',
|
|
481
|
+
body: { ...without(args), userId },
|
|
482
|
+
};
|
|
483
|
+
case 'chusky_scroll':
|
|
484
|
+
return {
|
|
485
|
+
method: 'POST',
|
|
486
|
+
path: `/tabs/${args.tabId}/scroll`,
|
|
487
|
+
auth: 'accessKey',
|
|
488
|
+
responseKind: 'json',
|
|
489
|
+
body: { ...without(args), userId },
|
|
490
|
+
};
|
|
491
|
+
case 'chusky_screenshot':
|
|
492
|
+
return {
|
|
493
|
+
method: 'GET',
|
|
494
|
+
path: `/tabs/${args.tabId}/screenshot?${new URLSearchParams({ userId })}`,
|
|
495
|
+
auth: 'accessKey',
|
|
496
|
+
responseKind: 'image',
|
|
497
|
+
};
|
|
498
|
+
case 'chusky_close_tab':
|
|
499
|
+
return {
|
|
500
|
+
method: 'DELETE',
|
|
501
|
+
path: `/tabs/${args.tabId}?${new URLSearchParams({ userId })}`,
|
|
502
|
+
auth: 'accessKey',
|
|
503
|
+
responseKind: 'json',
|
|
504
|
+
};
|
|
505
|
+
case 'chusky_evaluate':
|
|
506
|
+
return {
|
|
507
|
+
method: 'POST',
|
|
508
|
+
path: `/tabs/${args.tabId}/evaluate`,
|
|
509
|
+
auth: 'accessKey',
|
|
510
|
+
responseKind: 'json',
|
|
511
|
+
body: { userId, expression: args.expression },
|
|
512
|
+
};
|
|
513
|
+
case 'chusky_list_tabs':
|
|
514
|
+
return {
|
|
515
|
+
method: 'GET',
|
|
516
|
+
path: `/tabs?${new URLSearchParams({ userId })}`,
|
|
517
|
+
auth: 'accessKey',
|
|
518
|
+
responseKind: 'json',
|
|
519
|
+
};
|
|
520
|
+
case 'chusky_import_cookies':
|
|
521
|
+
// Async (Netscape parse + path check) — caller must use buildCookieRequest().
|
|
522
|
+
throw new Error('chusky_import_cookies requires buildCookieRequest() (async cookie parsing)');
|
|
523
|
+
case 'chusky_handoff_start':
|
|
524
|
+
return {
|
|
525
|
+
method: 'POST',
|
|
526
|
+
path: `/tabs/${encodeURIComponent(args.tabId)}/handoff`,
|
|
527
|
+
auth: 'accessKey',
|
|
528
|
+
responseKind: 'json',
|
|
529
|
+
body: { userId, reason: args.reason, idleMs: args.idleMs },
|
|
530
|
+
};
|
|
531
|
+
case 'chusky_handoff_release':
|
|
532
|
+
return {
|
|
533
|
+
method: 'POST',
|
|
534
|
+
path: `/tabs/${encodeURIComponent(args.tabId)}/handoff/release`,
|
|
535
|
+
auth: 'accessKey',
|
|
536
|
+
responseKind: 'json',
|
|
537
|
+
body: { userId },
|
|
538
|
+
};
|
|
539
|
+
case 'chusky_pay_arm':
|
|
540
|
+
return {
|
|
541
|
+
method: 'POST',
|
|
542
|
+
path: `/agentcard/tabs/${encodeURIComponent(args.tabId)}/payment`,
|
|
543
|
+
auth: 'accessKey',
|
|
544
|
+
responseKind: 'json',
|
|
545
|
+
body: {
|
|
546
|
+
userId,
|
|
547
|
+
merchant: args.merchant,
|
|
548
|
+
amount: args.amount,
|
|
549
|
+
currency: args.currency,
|
|
550
|
+
checkoutOrigin: args.checkoutOrigin,
|
|
551
|
+
cardId: args.cardId,
|
|
552
|
+
},
|
|
553
|
+
};
|
|
554
|
+
case 'chusky_pay_status':
|
|
555
|
+
return {
|
|
556
|
+
method: 'GET',
|
|
557
|
+
path: `/agentcard/tabs/${encodeURIComponent(args.tabId)}/payment/${encodeURIComponent(args.attachmentId)}?${new URLSearchParams({ userId })}`,
|
|
558
|
+
auth: 'accessKey',
|
|
559
|
+
responseKind: 'json',
|
|
560
|
+
};
|
|
561
|
+
case 'chusky_pay_disarm':
|
|
562
|
+
return {
|
|
563
|
+
method: 'DELETE',
|
|
564
|
+
path: `/agentcard/tabs/${encodeURIComponent(args.tabId)}/payment/${encodeURIComponent(args.attachmentId)}?${new URLSearchParams({ userId })}`,
|
|
565
|
+
auth: 'accessKey',
|
|
566
|
+
responseKind: 'json',
|
|
567
|
+
};
|
|
568
|
+
case 'agentcard_enrol':
|
|
569
|
+
return {
|
|
570
|
+
method: 'POST',
|
|
571
|
+
path: '/agentcard/vault/enrol',
|
|
572
|
+
auth: 'accessKey',
|
|
573
|
+
responseKind: 'json',
|
|
574
|
+
body: { userId, addAnother: args.addAnother === true },
|
|
575
|
+
};
|
|
576
|
+
case 'agentcard_placeholder':
|
|
577
|
+
return {
|
|
578
|
+
method: 'GET',
|
|
579
|
+
path: `/agentcard/cards/placeholder?${new URLSearchParams({ userId, ...(args.cardId ? { cardId: args.cardId } : {}) })}`,
|
|
580
|
+
auth: 'accessKey',
|
|
581
|
+
responseKind: 'json',
|
|
582
|
+
};
|
|
583
|
+
case 'agentcard_cards':
|
|
584
|
+
return {
|
|
585
|
+
method: 'GET',
|
|
586
|
+
path: `/agentcard/vault/cards?${new URLSearchParams({ userId })}`,
|
|
587
|
+
auth: 'accessKey',
|
|
588
|
+
responseKind: 'json',
|
|
589
|
+
};
|
|
590
|
+
case 'agentcard_buy': {
|
|
591
|
+
const body = { userId };
|
|
592
|
+
for (const key of ['ask', 'threadId', 'confirm', 'new_order', 'product_id', 'store_id']) {
|
|
593
|
+
if (args[key] !== undefined) body[key] = args[key];
|
|
594
|
+
}
|
|
595
|
+
return { method: 'POST', path: '/agentcard/commerce/buy', auth: 'accessKey', responseKind: 'json', body };
|
|
596
|
+
}
|
|
597
|
+
case 'agentcard_merchants': {
|
|
598
|
+
const params = new URLSearchParams({ userId });
|
|
599
|
+
for (const key of ['kind', 'q', 'limit', 'cursor']) {
|
|
600
|
+
if (args[key] !== undefined && args[key] !== '') params.set(key, String(args[key]));
|
|
601
|
+
}
|
|
602
|
+
return { method: 'GET', path: `/agentcard/merchants?${params}`, auth: 'accessKey', responseKind: 'json' };
|
|
603
|
+
}
|
|
604
|
+
case 'agentcard_presets_list':
|
|
605
|
+
return {
|
|
606
|
+
method: 'GET',
|
|
607
|
+
path: `/agentcard/presets?${new URLSearchParams({ userId })}`,
|
|
608
|
+
auth: 'accessKey',
|
|
609
|
+
responseKind: 'json',
|
|
610
|
+
};
|
|
611
|
+
case 'agentcard_preset_set':
|
|
612
|
+
return {
|
|
613
|
+
method: 'PUT',
|
|
614
|
+
path: `/agentcard/presets/${encodeURIComponent(args.label)}`,
|
|
615
|
+
auth: 'accessKey',
|
|
616
|
+
responseKind: 'json',
|
|
617
|
+
body: { userId, rules: args.rules },
|
|
618
|
+
};
|
|
619
|
+
case 'agentcard_preset_attach':
|
|
620
|
+
return {
|
|
621
|
+
method: 'POST',
|
|
622
|
+
path: `/agentcard/presets/${encodeURIComponent(args.label)}/attach`,
|
|
623
|
+
auth: 'accessKey',
|
|
624
|
+
responseKind: 'json',
|
|
625
|
+
body: { userId, cardId: args.cardId },
|
|
626
|
+
};
|
|
627
|
+
default:
|
|
628
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Cookie import is special: the Netscape file must be parsed locally (matching
|
|
634
|
+
* the OpenClaw plugin) and the REST route accepts a parsed `{ cookies: [...] }`
|
|
635
|
+
* body, never a path. This function does the parse and returns a request spec
|
|
636
|
+
* that the host fetch layer can dispatch like any other tool.
|
|
637
|
+
*
|
|
638
|
+
* @param {{cookiesPath: string, domainSuffix?: string}} args
|
|
639
|
+
* @param {CallContext} ctx
|
|
640
|
+
* @param {{apiKey: string, cookiesDir: string}} config - server config (apiKey + cookiesDir).
|
|
641
|
+
* @returns {Promise<RequestSpec & {meta: {imported: number, userId: string}}>}
|
|
642
|
+
* @throws {Error} if CHUSKY_API_KEY is unset.
|
|
643
|
+
*/
|
|
644
|
+
export async function buildCookieRequest(args, ctx, config) {
|
|
645
|
+
if (!config.apiKey) {
|
|
646
|
+
throw new Error(
|
|
647
|
+
'CHUSKY_API_KEY is not set. Cookie import is disabled unless both the server and the host (MCP/OpenClaw) have CHUSKY_API_KEY.'
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
const cookies = await readCookieFile({
|
|
651
|
+
cookiesDir: config.cookiesDir,
|
|
652
|
+
cookiesPath: args.cookiesPath,
|
|
653
|
+
domainSuffix: args.domainSuffix,
|
|
654
|
+
});
|
|
655
|
+
return {
|
|
656
|
+
method: 'POST',
|
|
657
|
+
path: `/sessions/${encodeURIComponent(ctx.userId)}/cookies`,
|
|
658
|
+
auth: 'apiKey',
|
|
659
|
+
responseKind: 'json',
|
|
660
|
+
body: { cookies },
|
|
661
|
+
meta: { imported: cookies.length, userId: ctx.userId },
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Resolve which bearer token a request needs, given server config.
|
|
667
|
+
* @param {RequestSpec} spec
|
|
668
|
+
* @param {{accessKey?: string, apiKey?: string}} config
|
|
669
|
+
* @returns {{header?: {Authorization: string}, missing?: string}}
|
|
670
|
+
*/
|
|
671
|
+
export function authHeaders(spec, config) {
|
|
672
|
+
if (spec.auth === 'accessKey') {
|
|
673
|
+
if (!config.accessKey) return {}; // server not gated — no header needed
|
|
674
|
+
return { Authorization: `Bearer ${config.accessKey}` };
|
|
675
|
+
}
|
|
676
|
+
if (spec.auth === 'apiKey') {
|
|
677
|
+
// apiKey presence is enforced by buildCookieRequest(); here we only attach it.
|
|
678
|
+
return { Authorization: `Bearer ${config.apiKey}` };
|
|
679
|
+
}
|
|
680
|
+
return {};
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Execute a request spec against a REST base URL. Shared by the MCP server and
|
|
685
|
+
* the OpenClaw plugin so the wire-level behavior (auth headers, image decoding,
|
|
686
|
+
* error formatting) is identical across hosts.
|
|
687
|
+
*
|
|
688
|
+
* @param {string} baseUrl - REST server origin (e.g. http://localhost:9377).
|
|
689
|
+
* @param {RequestSpec} spec
|
|
690
|
+
* @param {{accessKey?: string, apiKey?: string}} config
|
|
691
|
+
* @returns {Promise<unknown>} JSON value, or an image content block for image specs.
|
|
692
|
+
* @throws {Error} on non-2xx, or when an image route returns non-image bytes.
|
|
693
|
+
*/
|
|
694
|
+
export async function fetchSpec(baseUrl, spec, config) {
|
|
695
|
+
const headers = {
|
|
696
|
+
'Content-Type': 'application/json',
|
|
697
|
+
...authHeaders(spec, config),
|
|
698
|
+
};
|
|
699
|
+
const res = await fetch(`${baseUrl}${spec.path}`, {
|
|
700
|
+
method: spec.method,
|
|
701
|
+
headers,
|
|
702
|
+
body: spec.body ? JSON.stringify(spec.body) : undefined,
|
|
703
|
+
});
|
|
704
|
+
if (!res.ok) {
|
|
705
|
+
const text = await res.text();
|
|
706
|
+
throw new Error(`${res.status}: ${text}`);
|
|
707
|
+
}
|
|
708
|
+
if (spec.responseKind === 'image') {
|
|
709
|
+
const contentType = res.headers.get('content-type') || '';
|
|
710
|
+
// Guard: server may return JSON/text (e.g. error with 200) — don't base64 it.
|
|
711
|
+
if (!contentType.startsWith('image/')) {
|
|
712
|
+
const text = await res.text();
|
|
713
|
+
throw new Error(`Screenshot failed: ${text}`);
|
|
714
|
+
}
|
|
715
|
+
const data = Buffer.from(await res.arrayBuffer()).toString('base64');
|
|
716
|
+
return { type: 'image', data, mimeType: contentType };
|
|
717
|
+
}
|
|
718
|
+
return res.json();
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Build + dispatch a tool call end-to-end. The single entry point both hosts
|
|
723
|
+
* call, so a tool's full behavior (request shape + auth + transport + response
|
|
724
|
+
* shaping) lives in exactly one place. Hosts only differ in how they source
|
|
725
|
+
* `ctx` (userId/sessionKey) and `config`.
|
|
726
|
+
*
|
|
727
|
+
* @param {string} name - Tool name.
|
|
728
|
+
* @param {Record<string, unknown>} args - Tool arguments.
|
|
729
|
+
* @param {CallContext} ctx - { userId, sessionKey }.
|
|
730
|
+
* @param {string} baseUrl - REST server origin.
|
|
731
|
+
* @param {{apiKey?: string, accessKey?: string, cookiesDir: string}} config - server config.
|
|
732
|
+
* @returns {Promise<{spec: RequestSpec, payload: unknown}>}
|
|
733
|
+
*/
|
|
734
|
+
export async function runTool(name, args, ctx, baseUrl, config) {
|
|
735
|
+
const spec =
|
|
736
|
+
name === 'chusky_import_cookies'
|
|
737
|
+
? await buildCookieRequest(args, ctx, config)
|
|
738
|
+
: buildRequest(name, args, ctx);
|
|
739
|
+
const payload = await fetchSpec(baseUrl, spec, config);
|
|
740
|
+
return { spec, payload };
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Shape a REST JSON payload into MCP/OpenClaw content blocks.
|
|
745
|
+
*
|
|
746
|
+
* - snapshot: splits the embedded screenshot out as an image block
|
|
747
|
+
* - image: already an image block produced by the fetch layer
|
|
748
|
+
* - json (default): pretty-printed JSON text block
|
|
749
|
+
*
|
|
750
|
+
* @param {RequestSpec} spec
|
|
751
|
+
* @param {unknown} payload - JSON value ('json'/'snapshot') or an image block ('image').
|
|
752
|
+
* @returns {Array<{type: string, text?: string, data?: string, mimeType?: string}>}
|
|
753
|
+
*/
|
|
754
|
+
export function adaptResponse(spec, payload) {
|
|
755
|
+
if (spec.responseKind === 'image') {
|
|
756
|
+
return [payload];
|
|
757
|
+
}
|
|
758
|
+
if (spec.responseKind === 'snapshot') {
|
|
759
|
+
const { screenshot, ...rest } = /** @type {any} */ (payload) || {};
|
|
760
|
+
const content = [{ type: 'text', text: JSON.stringify(rest, null, 2) }];
|
|
761
|
+
if (screenshot?.data) {
|
|
762
|
+
content.push({
|
|
763
|
+
type: 'image',
|
|
764
|
+
data: screenshot.data,
|
|
765
|
+
mimeType: screenshot.mimeType || 'image/png',
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
return content;
|
|
769
|
+
}
|
|
770
|
+
// Cookie import: surface the parsed count alongside the server reply.
|
|
771
|
+
if (spec.meta && spec.meta.imported != null) {
|
|
772
|
+
return [
|
|
773
|
+
{
|
|
774
|
+
type: 'text',
|
|
775
|
+
text: JSON.stringify({ imported: spec.meta.imported, userId: spec.meta.userId, result: payload }, null, 2),
|
|
776
|
+
},
|
|
777
|
+
];
|
|
778
|
+
}
|
|
779
|
+
return [{ type: 'text', text: JSON.stringify(payload, null, 2) }];
|
|
780
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "chuskybrowser-mcp",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "chuskybrowser-mcp",
|
|
3
|
+
"version": "1.18.2",
|
|
4
|
+
"description": "Standalone stdio MCP server exposing the chuskybrowser REST API as MCP tools (Claude Code, Codex, agy, Cursor, opencode). Proxies to an already-running chuskybrowser REST server — does not itself require the browser binary, Playwright, or the core server's dependencies. Initial MCP implementation contributed by @epicsagas.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "server.mjs",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "Jo Inc <oss@askjo.ai>",
|
|
9
|
+
"homepage": "https://github.com/zester4/chuskybrowser/tree/master/mcp#readme",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/zester4/chuskybrowser.git",
|
|
13
|
+
"directory": "mcp"
|
|
14
|
+
},
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/zester4/chuskybrowser/issues"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"mcp",
|
|
20
|
+
"model-context-protocol",
|
|
21
|
+
"chusky",
|
|
22
|
+
"camoufox",
|
|
23
|
+
"browser-automation",
|
|
24
|
+
"claude-code",
|
|
25
|
+
"codex",
|
|
26
|
+
"cursor",
|
|
27
|
+
"opencode"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=22"
|
|
31
|
+
},
|
|
32
|
+
"bin": {
|
|
33
|
+
"chuskybrowser-mcp": "server.mjs"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"server.mjs",
|
|
37
|
+
"lib/",
|
|
38
|
+
"README.md"
|
|
39
|
+
],
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@modelcontextprotocol/sdk": "^1.30.0"
|
|
42
|
+
},
|
|
43
|
+
"overrides": {
|
|
44
|
+
"fast-uri": "3.1.8",
|
|
45
|
+
"hono": "4.13.7",
|
|
46
|
+
"qs": "6.16.0"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/server.mjs
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// chuskybrowser MCP server
|
|
3
|
+
//
|
|
4
|
+
// Standalone Model Context Protocol server that exposes the chuskybrowser
|
|
5
|
+
// REST API (default http://localhost:9377) as MCP tools. Tool names, schemas,
|
|
6
|
+
// REST routes, request bodies, auth, and response shaping are imported from
|
|
7
|
+
// mcp/lib/tool-contracts.mjs — the SAME source of truth the OpenClaw plugin
|
|
8
|
+
// (plugin.ts) uses — so behavior is identical whether an agent reaches chusky
|
|
9
|
+
// via OpenClaw or MCP. Drift is structurally impossible.
|
|
10
|
+
//
|
|
11
|
+
// Transport: stdio (Claude Code / Codex / agy / Cursor / opencode spawn this as
|
|
12
|
+
// a child process). The chusky REST server itself must be running (npm start) —
|
|
13
|
+
// this server only forwards calls, it does not launch the browser.
|
|
14
|
+
//
|
|
15
|
+
// Auth:
|
|
16
|
+
// - CHUSKY_ACCESS_KEY (global): forwarded as `Authorization: Bearer` on every
|
|
17
|
+
// request so globally-authenticated REST servers accept MCP traffic.
|
|
18
|
+
// - CHUSKY_API_KEY (cookie import): forwarded as `Authorization: Bearer` on
|
|
19
|
+
// the cookie-import route only.
|
|
20
|
+
|
|
21
|
+
import { randomUUID } from "node:crypto";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import { readFileSync } from "node:fs";
|
|
25
|
+
|
|
26
|
+
import { loadMcpConfig } from "./lib/config.mjs";
|
|
27
|
+
import {
|
|
28
|
+
TOOL_DEFS,
|
|
29
|
+
runTool,
|
|
30
|
+
adaptResponse,
|
|
31
|
+
} from "./lib/tool-contracts.mjs";
|
|
32
|
+
|
|
33
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
34
|
+
|
|
35
|
+
// Version from this package's own package.json (single source — no hardcoded
|
|
36
|
+
// duplicate). mcp/ is an independently installable package (chuskybrowser-mcp)
|
|
37
|
+
// with its own manifest, so this reads locally rather than the parent repo's.
|
|
38
|
+
const VERSION = JSON.parse(
|
|
39
|
+
readFileSync(join(__dirname, "package.json"), "utf8")
|
|
40
|
+
).version;
|
|
41
|
+
|
|
42
|
+
// Server config (apiKey / accessKey / cookiesDir / port). The standalone
|
|
43
|
+
// package reads only its own environment settings; CHUSKY_BASE_URL overrides
|
|
44
|
+
// the derived local URL.
|
|
45
|
+
const CONFIG = loadMcpConfig();
|
|
46
|
+
const BASE_URL = process.env.CHUSKY_BASE_URL || `http://localhost:${CONFIG.port}`;
|
|
47
|
+
|
|
48
|
+
// Per-MCP-server userId so each host session gets an isolated chusky session
|
|
49
|
+
// (cookie/storage partition). Falls back to a random id.
|
|
50
|
+
const USER_ID = process.env.CHUSKY_USER_ID || `mcp-${randomUUID()}`;
|
|
51
|
+
// sessionKey partitions tabs within a user (matches plugin.ts fallback "default").
|
|
52
|
+
const SESSION_KEY = process.env.CHUSKY_SESSION_KEY || "default";
|
|
53
|
+
|
|
54
|
+
// The standalone package declares the SDK directly. Surface a clear,
|
|
55
|
+
// actionable error if an incomplete installation is missing it.
|
|
56
|
+
let Server, StdioServerTransport, CallToolRequestSchema, ListToolsRequestSchema;
|
|
57
|
+
try {
|
|
58
|
+
const serverMod = await import("@modelcontextprotocol/sdk/server/index.js");
|
|
59
|
+
Server = serverMod.Server;
|
|
60
|
+
const stdioMod = await import("@modelcontextprotocol/sdk/server/stdio.js");
|
|
61
|
+
StdioServerTransport = stdioMod.StdioServerTransport;
|
|
62
|
+
const typesMod = await import("@modelcontextprotocol/sdk/types.js");
|
|
63
|
+
CallToolRequestSchema = typesMod.CallToolRequestSchema;
|
|
64
|
+
ListToolsRequestSchema = typesMod.ListToolsRequestSchema;
|
|
65
|
+
} catch {
|
|
66
|
+
console.error(
|
|
67
|
+
"[chuskybrowser-mcp] @modelcontextprotocol/sdk is not installed.\n" +
|
|
68
|
+
"Install the adapter dependencies with: npm install\n" +
|
|
69
|
+
"from the chuskybrowser-mcp package directory."
|
|
70
|
+
);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const server = new Server(
|
|
75
|
+
{ name: "chuskybrowser", version: VERSION },
|
|
76
|
+
{ capabilities: { tools: {} } }
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
80
|
+
tools: TOOL_DEFS.map((t) => ({
|
|
81
|
+
name: t.name,
|
|
82
|
+
description: t.description,
|
|
83
|
+
inputSchema: t.inputSchema,
|
|
84
|
+
})),
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
88
|
+
const { name, arguments: args } = req.params;
|
|
89
|
+
const def = TOOL_DEFS.find((t) => t.name === name);
|
|
90
|
+
if (!def) {
|
|
91
|
+
return {
|
|
92
|
+
isError: true,
|
|
93
|
+
content: [{ type: "text", text: `Unknown tool: ${name}` }],
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const { spec, payload } = await runTool(
|
|
98
|
+
name,
|
|
99
|
+
args || {},
|
|
100
|
+
{ userId: USER_ID, sessionKey: SESSION_KEY },
|
|
101
|
+
BASE_URL,
|
|
102
|
+
CONFIG
|
|
103
|
+
);
|
|
104
|
+
const content = adaptResponse(spec, payload);
|
|
105
|
+
return { content };
|
|
106
|
+
} catch (err) {
|
|
107
|
+
return {
|
|
108
|
+
isError: true,
|
|
109
|
+
content: [{ type: "text", text: `chusky error: ${err.message}` }],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const transport = new StdioServerTransport();
|
|
115
|
+
await server.connect(transport);
|
|
116
|
+
console.error(
|
|
117
|
+
`[chuskybrowser-mcp] v${VERSION} connected → ${BASE_URL} (user=${USER_ID})`
|
|
118
|
+
);
|