aipex-mcp-bridge 2.1.0 → 3.1.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 CHANGED
@@ -1,44 +1,39 @@
1
1
  # aipex-mcp-bridge
2
2
 
3
- MCP bridge that connects AI agents to the [AIPex](https://aipex.ai) browser extension via WebSocket.
4
-
5
- Works with **any** MCP client that supports stdio transport — Cursor, Claude Desktop, Claude Code, VS Code Copilot, Windsurf, Zed, and more.
3
+ MCP server that connects AI agents to the [AIPex](https://aipex.ai) browser extension. Supports **multiple simultaneous clients** (Cursor, Claude Code, VS Code Copilot, etc.) via StreamableHTTP.
6
4
 
7
5
  ## How it works
8
6
 
9
7
  ```
10
- AI Agent (MCP client) ──stdio──▶ aipex-mcp-bridge ──WebSocket──▶ AIPex Chrome Extension
8
+ Cursor ──HTTP POST /mcp──┐
9
+ Claude Code ──HTTP POST /mcp──┤── aipex-mcp-server ──WebSocket──▶ AIPex Chrome Extension
10
+ VS Code ──HTTP POST /mcp──┘
11
11
  ```
12
12
 
13
- The bridge starts a WebSocket server on `localhost:9223` (configurable) and communicates with your AI agent over stdio using the MCP protocol. The AIPex extension connects to the WebSocket server to expose browser control tools.
13
+ The server runs on `localhost:9223` and provides:
14
+ - **`/mcp`** — StreamableHTTP endpoint for MCP clients
15
+ - **`/extension`** — WebSocket endpoint for the AIPex Chrome extension
16
+ - **`/health`** — Health check endpoint
14
17
 
15
18
  ## Quick start
16
19
 
17
- ### 1. Configure your AI agent
20
+ ### 1. Start the server
18
21
 
19
- Add the following to your agent's MCP configuration:
22
+ ```bash
23
+ npx aipex-mcp-server
24
+ ```
20
25
 
21
- **Cursor** (`.cursor/mcp.json`):
26
+ The server stays running and handles all AI agent connections.
22
27
 
23
- ```json
24
- {
25
- "mcpServers": {
26
- "aipex-browser": {
27
- "command": "npx",
28
- "args": ["-y", "aipex-mcp-bridge"]
29
- }
30
- }
31
- }
32
- ```
28
+ ### 2. Configure your AI agent
33
29
 
34
- **Claude Desktop** (`claude_desktop_config.json`):
30
+ **Cursor** (`.cursor/mcp.json` or `~/.cursor/mcp.json`):
35
31
 
36
32
  ```json
37
33
  {
38
34
  "mcpServers": {
39
35
  "aipex-browser": {
40
- "command": "npx",
41
- "args": ["-y", "aipex-mcp-bridge"]
36
+ "url": "http://localhost:9223/mcp"
42
37
  }
43
38
  }
44
39
  }
@@ -47,7 +42,7 @@ Add the following to your agent's MCP configuration:
47
42
  **Claude Code**:
48
43
 
49
44
  ```bash
50
- claude mcp add aipex-browser -- npx -y aipex-mcp-bridge
45
+ claude mcp add --transport http aipex-browser http://localhost:9223/mcp
51
46
  ```
52
47
 
53
48
  **VS Code Copilot** (`.vscode/mcp.json`):
@@ -56,69 +51,57 @@ claude mcp add aipex-browser -- npx -y aipex-mcp-bridge
56
51
  {
57
52
  "servers": {
58
53
  "aipex-browser": {
59
- "command": "npx",
60
- "args": ["-y", "aipex-mcp-bridge"]
61
- }
62
- }
63
- }
64
- ```
65
-
66
- **Windsurf** (`mcp_config.json`):
67
-
68
- ```json
69
- {
70
- "mcpServers": {
71
- "aipex-browser": {
72
- "command": "npx",
73
- "args": ["-y", "aipex-mcp-bridge"]
54
+ "url": "http://localhost:9223/mcp"
74
55
  }
75
56
  }
76
57
  }
77
58
  ```
78
59
 
79
- ### 2. Connect AIPex extension
60
+ ### 3. Connect AIPex extension
80
61
 
81
62
  1. Open Chrome → AIPex extension → Options page
82
- 2. Set WebSocket URL to `ws://localhost:9223`
63
+ 2. Set WebSocket URL to `ws://localhost:9223/extension`
83
64
  3. Click **Connect**
84
65
 
85
- Your AI agent can now control the browser through AIPex.
66
+ Your AI agents can now control the browser through AIPex — all simultaneously.
86
67
 
87
68
  ## Options
88
69
 
89
70
  ```
90
- npx aipex-mcp-bridge [--port <port>] [--host <host>]
71
+ npx aipex-mcp-server [--port <port>] [--host <host>]
91
72
  ```
92
73
 
93
- | Option | Default | Description |
94
- | ----------------- | ----------- | ----------------------------------------------------------- |
95
- | `--port <port>` | `9223` | WebSocket port for AIPex extension |
96
- | `--host <host>` | `127.0.0.1` | Bind address (`0.0.0.0` to allow remote/Docker connections) |
97
- | `--help`, `-h` | | Show help message |
98
- | `--version`, `-v` | | Show version |
74
+ | Option | Default | Description |
75
+ | --------------- | ----------- | ----------------------------------------------------------- |
76
+ | `--port <port>` | `9223` | Server port |
77
+ | `--host <host>` | `127.0.0.1` | Bind address (`0.0.0.0` to allow remote/Docker connections) |
78
+ | `--help`, `-h` | | Show help message |
79
+ | `--version`, `-v`| | Show version |
80
+
81
+ ---
82
+
83
+ ## Stdio Bridge (backward compatibility)
99
84
 
100
- ### Custom port example
85
+ For MCP clients that only support stdio transport, a thin bridge is included:
101
86
 
102
87
  ```json
103
88
  {
104
89
  "mcpServers": {
105
90
  "aipex-browser": {
106
91
  "command": "npx",
107
- "args": ["-y", "aipex-mcp-bridge", "--port", "8080"]
92
+ "args": ["-y", "aipex-mcp-bridge"]
108
93
  }
109
94
  }
110
95
  }
111
96
  ```
112
97
 
98
+ The stdio bridge forwards tool calls to the HTTP server at `http://localhost:9223/mcp`. The server must be running separately.
99
+
113
100
  ---
114
101
 
115
102
  ## AIPex CLI
116
103
 
117
- This package also includes `aipex-cli`, a command-line tool for controlling the browser directly from the terminal without an MCP client.
118
-
119
- ### Prerequisites
120
-
121
- The bridge must be running with the AIPex extension connected. In Docker (`butterman2/aipex-browser`), this is automatic.
104
+ Command-line tool for controlling the browser directly from the terminal.
122
105
 
123
106
  ### Usage
124
107
 
@@ -129,12 +112,6 @@ aipex-cli --help <tool_name> # Show tool parameters
129
112
  aipex-cli --json '{"name":"...","arguments":{...}}' # Raw JSON
130
113
  ```
131
114
 
132
- ### Environment Variables
133
-
134
- | Variable | Default | Description |
135
- | -------------- | ------------------------- | ----------------------- |
136
- | `AIPEX_WS_URL` | `ws://localhost:9223/cli` | Bridge CLI endpoint URL |
137
-
138
115
  ### Examples
139
116
 
140
117
  ```bash
@@ -145,19 +122,18 @@ aipex-cli click --tabId 123 --uid btn-42
145
122
  aipex-cli capture_screenshot
146
123
  ```
147
124
 
148
- ### Docker usage
125
+ ### Environment Variables
149
126
 
150
- ```bash
151
- docker exec aipex aipex-cli get_all_tabs
152
- docker exec aipex aipex-cli create_new_tab --url https://google.com
153
- ```
127
+ | Variable | Default | Description |
128
+ | --------------------- | --------------------------------- | ----------------------- |
129
+ | `AIPEX_SERVER_URL` | `http://localhost:9223/mcp` | HTTP server URL |
130
+ | `AIPEX_WS_URL` | `ws://localhost:9223/cli` | WebSocket fallback URL |
131
+ | `AIPEX_CONNECT_TIMEOUT`| `60000` | Max ms to wait |
154
132
 
155
133
  ---
156
134
 
157
135
  ## Docker Image
158
136
 
159
- The AIPex Docker image provides a fully self-contained browser automation environment:
160
-
161
137
  ```bash
162
138
  docker pull butterman2/aipex-browser:latest
163
139
  docker run -d --name aipex --shm-size=2g \
@@ -165,25 +141,24 @@ docker run -d --name aipex --shm-size=2g \
165
141
  butterman2/aipex-browser:latest
166
142
  ```
167
143
 
168
- Multi-architecture support: `linux/amd64` and `linux/arm64`.
169
-
170
144
  | Port | Service |
171
145
  | ---- | -------------------- |
172
- | 9223 | MCP Bridge WebSocket |
146
+ | 9223 | MCP Server (HTTP + WS) |
173
147
  | 5900 | VNC |
174
148
  | 6080 | noVNC (web-based) |
175
149
 
176
- ### Publishing to DockerHub
150
+ ## Migration from v2.x
177
151
 
178
- ```bash
179
- docker login -u butterman2
180
- docker buildx create --name aipex-builder --use 2>/dev/null || docker buildx use aipex-builder
181
- docker buildx build \
182
- --platform linux/amd64,linux/arm64 \
183
- -t butterman2/aipex-browser:latest \
184
- -t butterman2/aipex-browser:$(node -p "require('./package.json').version") \
185
- --push .
186
- ```
152
+ v3.0 replaces the daemon+proxy architecture with a single HTTP server:
153
+
154
+ | v2.x (daemon) | v3.0 (HTTP server) |
155
+ | ------------------------------------------ | ----------------------------------------- |
156
+ | `npx aipex-mcp-bridge` (stdio per IDE) | `npx aipex-mcp-server` (one server) |
157
+ | Each IDE spawns its own bridge process | All IDEs connect to one HTTP endpoint |
158
+ | Daemon with PID files, idle timeout | Standard HTTP server, no background process|
159
+ | Extension connects to `ws://localhost:9223` | Extension connects to `ws://localhost:9223/extension` |
160
+
161
+ **Breaking change**: The AIPex extension WebSocket URL changed from `ws://localhost:9223` to `ws://localhost:9223/extension`. Update the URL in AIPex extension Options.
187
162
 
188
163
  ## Requirements
189
164
 
package/dist/bridge.js CHANGED
@@ -4,270 +4,193 @@ import {
4
4
  } from "./chunk-DHFGE3G7.js";
5
5
 
6
6
  // src/bridge.ts
7
+ import { fork } from "child_process";
8
+ import { fileURLToPath } from "url";
9
+ import { dirname, join } from "path";
7
10
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
8
11
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
12
  import {
10
13
  CallToolRequestSchema,
11
14
  ListToolsRequestSchema
12
15
  } from "@modelcontextprotocol/sdk/types.js";
13
-
14
- // src/proxy-client.ts
15
- import { spawn } from "child_process";
16
- import { existsSync, readFileSync } from "fs";
17
- import { homedir } from "os";
18
- import { join, dirname } from "path";
19
- import { fileURLToPath } from "url";
20
16
  import { WebSocket } from "ws";
21
- var PID_FILE = join(homedir(), ".aipex-mcp-daemon.pid");
22
- var CONNECT_TIMEOUT_MS = 1e4;
23
- var CONNECT_RETRY_INTERVAL_MS = 300;
24
- var TOOL_CALL_TIMEOUT_MS = 6e4;
25
- function log(msg) {
26
- process.stderr.write(`[aipex-bridge] ${msg}
27
- `);
28
- }
29
- var ProxyClient = class {
30
- ws = null;
31
- nextId = 1;
32
- pending = /* @__PURE__ */ new Map();
33
- extensionConnected = false;
34
- onStatusChange;
35
- setStatusHandler(handler) {
36
- this.onStatusChange = handler;
37
- }
38
- isConnected() {
39
- return !!this.ws && this.ws.readyState === WebSocket.OPEN;
40
- }
41
- isExtensionConnected() {
42
- return this.extensionConnected;
43
- }
44
- /**
45
- * Connect to the daemon, auto-launching it if needed.
46
- */
47
- async connect(proxyPort, extPort, host) {
48
- const url = `ws://${host}:${proxyPort}`;
49
- if (await this.tryConnect(url)) {
50
- return;
51
- }
52
- log("Daemon not running, launching...");
53
- await this.launchDaemon(extPort, proxyPort, host);
54
- const deadline = Date.now() + CONNECT_TIMEOUT_MS;
55
- while (Date.now() < deadline) {
56
- await sleep(CONNECT_RETRY_INTERVAL_MS);
57
- if (await this.tryConnect(url)) {
58
- return;
59
- }
60
- }
61
- throw new Error(
62
- `Could not connect to daemon at ${url} after ${CONNECT_TIMEOUT_MS / 1e3}s`
63
- );
64
- }
65
- async tryConnect(url) {
66
- return new Promise((resolve2) => {
67
- const ws = new WebSocket(url);
68
- const timer = setTimeout(() => {
69
- ws.terminate();
70
- resolve2(false);
71
- }, 2e3);
72
- ws.on("open", () => {
73
- clearTimeout(timer);
74
- this.attachSocket(ws);
75
- resolve2(true);
76
- });
77
- ws.on("error", () => {
78
- clearTimeout(timer);
79
- resolve2(false);
80
- });
81
- });
82
- }
83
- attachSocket(ws) {
84
- this.ws = ws;
85
- ws.on("message", (data) => {
86
- this.handleMessage(data.toString());
87
- });
88
- ws.on("close", () => {
89
- log("Disconnected from daemon");
90
- this.ws = null;
91
- this.rejectAll("Disconnected from daemon");
92
- });
93
- ws.on("error", (err) => {
94
- log(`Daemon connection error: ${err.message}`);
95
- });
96
- log("Connected to daemon");
97
- }
98
- handleMessage(raw) {
99
- let msg;
100
- try {
101
- msg = JSON.parse(raw);
102
- } catch {
103
- return;
104
- }
105
- if (msg.method === "status" && msg.params) {
106
- const params = msg.params;
107
- this.extensionConnected = !!params.extensionConnected;
108
- this.onStatusChange?.(this.extensionConnected);
109
- return;
110
- }
111
- const id = msg.id;
112
- if (id == null) return;
113
- const pending = this.pending.get(id);
114
- if (!pending) return;
115
- clearTimeout(pending.timer);
116
- this.pending.delete(id);
117
- if (msg.error) {
118
- const err = msg.error;
119
- pending.reject(new Error(err.message || "Daemon returned an error"));
120
- } else {
121
- pending.resolve(msg.result);
122
- }
123
- }
124
- /**
125
- * Send a tool call through the daemon to the extension.
126
- */
127
- async sendToolCall(toolName, args = {}, timeoutMs = TOOL_CALL_TIMEOUT_MS) {
128
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
129
- throw new Error(
130
- "Not connected to daemon. The bridge may need to be restarted."
131
- );
132
- }
133
- const id = this.nextId++;
134
- const msg = {
135
- jsonrpc: "2.0",
136
- id,
137
- method: "tools/call",
138
- params: { name: toolName, arguments: args }
139
- };
140
- this.ws.send(JSON.stringify(msg));
141
- return new Promise((resolve2, reject) => {
142
- const timer = setTimeout(() => {
143
- if (this.pending.has(id)) {
144
- this.pending.delete(id);
145
- reject(new Error(`Tool '${toolName}' timed out after ${timeoutMs}ms`));
146
- }
147
- }, timeoutMs);
148
- this.pending.set(id, { resolve: resolve2, reject, timer });
149
- });
150
- }
151
- async close() {
152
- this.rejectAll("Bridge shutting down");
153
- if (this.ws) {
154
- this.ws.close();
155
- this.ws = null;
156
- }
157
- }
158
- rejectAll(reason) {
159
- for (const [, entry] of this.pending) {
160
- clearTimeout(entry.timer);
161
- entry.reject(new Error(reason));
162
- }
163
- this.pending.clear();
164
- }
165
- async launchDaemon(extPort, proxyPort, host) {
166
- if (isDaemonAlive()) {
167
- log("Daemon PID file exists and process is alive, skipping launch");
168
- return;
169
- }
170
- const daemonScript = getDaemonPath();
171
- log(`Launching daemon: node ${daemonScript}`);
172
- const child = spawn(
173
- process.execPath,
174
- [
175
- daemonScript,
176
- "--port",
177
- String(extPort),
178
- "--proxy-port",
179
- String(proxyPort),
180
- "--host",
181
- host
182
- ],
183
- {
184
- detached: true,
185
- stdio: "ignore",
186
- env: { ...process.env }
187
- }
188
- );
189
- child.on("error", (err) => {
190
- log(`Failed to launch daemon: ${err.message}`);
191
- });
192
- child.unref();
193
- await sleep(500);
194
- }
195
- };
196
- function getDaemonPath() {
197
- const thisFile = fileURLToPath(import.meta.url);
198
- const thisDir = dirname(thisFile);
199
- const candidates = [
200
- join(thisDir, "daemon.js"),
201
- join(thisDir, "daemon.ts")
202
- ];
203
- for (const p of candidates) {
204
- if (existsSync(p)) return p;
205
- }
206
- return candidates[0];
207
- }
208
- function isDaemonAlive() {
209
- try {
210
- if (!existsSync(PID_FILE)) return false;
211
- const content = readFileSync(PID_FILE, "utf-8").trim();
212
- const pid = parseInt(content.split("\n")[0], 10);
213
- if (isNaN(pid)) return false;
214
- process.kill(pid, 0);
215
- return true;
216
- } catch {
217
- return false;
218
- }
219
- }
220
- function sleep(ms) {
221
- return new Promise((r) => setTimeout(r, ms));
222
- }
223
-
224
- // src/bridge.ts
225
17
  var cliArgs = process.argv.slice(2);
226
18
  if (cliArgs.includes("--help") || cliArgs.includes("-h")) {
227
19
  process.stderr.write(`
228
20
  AIPex MCP Bridge \u2014 connect AI agents to AIPex browser extension
229
21
 
230
22
  Usage:
231
- npx aipex-mcp-bridge [--port <port>] [--proxy-port <port>] [--host <host>]
23
+ npx aipex-mcp-bridge [--port <port>] [--host <host>]
232
24
 
233
25
  Options:
234
- --port <port> WebSocket port for AIPex extension (default: 9223)
235
- --proxy-port <port> Daemon proxy port (default: 9224)
236
- --host <host> Bind address (default: 127.0.0.1)
237
- --help, -h Show this help message
238
- --version, -v Show version
26
+ --port <port> Daemon port (default: 9223)
27
+ --host <host> Daemon host (default: 127.0.0.1)
28
+ --help, -h Show this help message
29
+ --version, -v Show version
239
30
 
240
- Multiple IDEs can run this bridge simultaneously \u2014 they share a single
241
- daemon process that manages the extension connection.
31
+ The bridge auto-starts a background daemon if one isn't already running.
32
+ Multiple IDE instances (Cursor, Claude Code) can run simultaneously.
242
33
 
243
- After starting, open AIPex extension Options and connect to:
244
- ws://localhost:<port>
34
+ After starting, connect AIPex extension \u2192 Options \u2192 ws://localhost:<port>/extension
245
35
  `);
246
36
  process.exit(0);
247
37
  }
248
38
  if (cliArgs.includes("--version") || cliArgs.includes("-v")) {
249
- process.stderr.write("aipex-mcp-bridge 2.1.0\n");
39
+ process.stderr.write("aipex-mcp-bridge 3.1.0\n");
250
40
  process.exit(0);
251
41
  }
252
42
  function getArg(name, fallback) {
253
43
  const idx = cliArgs.indexOf(name);
254
44
  return idx !== -1 && cliArgs[idx + 1] ? cliArgs[idx + 1] : fallback;
255
45
  }
256
- var EXT_PORT = parseInt(getArg("--port", "9223"), 10);
257
- var PROXY_PORT = parseInt(getArg("--proxy-port", "9224"), 10);
258
- var WS_HOST = getArg("--host", "127.0.0.1");
259
- if (isNaN(EXT_PORT) || EXT_PORT < 1 || EXT_PORT > 65535) {
260
- process.stderr.write(`Invalid port number. Must be between 1 and 65535.
261
- `);
262
- process.exit(1);
263
- }
264
- function log2(msg) {
46
+ var PORT = parseInt(getArg("--port", "9223"), 10);
47
+ var HOST = getArg("--host", "127.0.0.1");
48
+ var DAEMON_URL = `ws://${HOST}:${PORT}/bridge`;
49
+ var MAX_CONNECT_ATTEMPTS = 10;
50
+ var INITIAL_BACKOFF_MS = 300;
51
+ var TOOL_CALL_TIMEOUT_MS = 6e4;
52
+ function log(msg) {
265
53
  process.stderr.write(`[aipex-bridge] ${msg}
266
54
  `);
267
55
  }
268
- var proxyClient = new ProxyClient();
56
+ var daemonWs;
57
+ var nextReqId = 1;
58
+ var pendingCalls = /* @__PURE__ */ new Map();
59
+ function isDaemonConnected() {
60
+ return !!daemonWs && daemonWs.readyState === WebSocket.OPEN;
61
+ }
62
+ function sendToolCallToDaemon(toolName, args) {
63
+ if (!isDaemonConnected()) {
64
+ return Promise.reject(
65
+ new Error(
66
+ "Not connected to AIPex daemon. The daemon may have stopped.\nRestart the bridge or check if port " + PORT + " is available."
67
+ )
68
+ );
69
+ }
70
+ const id = nextReqId++;
71
+ const msg = {
72
+ jsonrpc: "2.0",
73
+ id,
74
+ method: "tools/call",
75
+ params: { name: toolName, arguments: args }
76
+ };
77
+ daemonWs.send(JSON.stringify(msg));
78
+ return new Promise((resolve, reject) => {
79
+ const timer = setTimeout(() => {
80
+ if (pendingCalls.has(id)) {
81
+ pendingCalls.delete(id);
82
+ reject(new Error(`Tool '${toolName}' timed out after ${TOOL_CALL_TIMEOUT_MS}ms`));
83
+ }
84
+ }, TOOL_CALL_TIMEOUT_MS);
85
+ pendingCalls.set(id, { resolve, reject, timer });
86
+ });
87
+ }
88
+ function handleDaemonMessage(raw) {
89
+ let msg;
90
+ try {
91
+ msg = JSON.parse(raw);
92
+ } catch {
93
+ return;
94
+ }
95
+ const id = msg.id;
96
+ if (id == null) return;
97
+ const pending = pendingCalls.get(id);
98
+ if (!pending) return;
99
+ clearTimeout(pending.timer);
100
+ pendingCalls.delete(id);
101
+ if (msg.error) {
102
+ const err = msg.error;
103
+ pending.reject(new Error(err.message || "Daemon returned an error"));
104
+ } else {
105
+ pending.resolve(msg.result);
106
+ }
107
+ }
108
+ function rejectAllPending(reason) {
109
+ for (const [, entry] of pendingCalls) {
110
+ clearTimeout(entry.timer);
111
+ entry.reject(new Error(reason));
112
+ }
113
+ pendingCalls.clear();
114
+ }
115
+ function tryConnectToDaemon() {
116
+ return new Promise((resolve, reject) => {
117
+ const ws = new WebSocket(DAEMON_URL);
118
+ const timeout = setTimeout(() => {
119
+ ws.terminate();
120
+ reject(new Error("Connection timeout"));
121
+ }, 3e3);
122
+ ws.on("open", () => {
123
+ clearTimeout(timeout);
124
+ resolve(ws);
125
+ });
126
+ ws.on("error", (err) => {
127
+ clearTimeout(timeout);
128
+ reject(err);
129
+ });
130
+ });
131
+ }
132
+ function spawnDaemon() {
133
+ const __filename = fileURLToPath(import.meta.url);
134
+ const __dirname = dirname(__filename);
135
+ const daemonPath = join(__dirname, "daemon.js");
136
+ log(`Spawning daemon: ${daemonPath} --port ${PORT} --host ${HOST}`);
137
+ const child = fork(daemonPath, ["--port", String(PORT), "--host", HOST], {
138
+ detached: true,
139
+ stdio: "ignore"
140
+ });
141
+ child.unref();
142
+ child.on("error", (err) => {
143
+ log(`Failed to spawn daemon: ${err.message}`);
144
+ });
145
+ }
146
+ function sleep(ms) {
147
+ return new Promise((r) => setTimeout(r, ms));
148
+ }
149
+ async function connectWithAutoSpawn() {
150
+ try {
151
+ const ws = await tryConnectToDaemon();
152
+ log("Connected to existing daemon");
153
+ return ws;
154
+ } catch {
155
+ }
156
+ log("No daemon running, spawning one...");
157
+ spawnDaemon();
158
+ let backoff = INITIAL_BACKOFF_MS;
159
+ for (let attempt = 1; attempt <= MAX_CONNECT_ATTEMPTS; attempt++) {
160
+ await sleep(backoff);
161
+ try {
162
+ const ws = await tryConnectToDaemon();
163
+ log(`Connected to daemon (attempt ${attempt})`);
164
+ return ws;
165
+ } catch {
166
+ backoff = Math.min(backoff * 1.5, 2e3);
167
+ }
168
+ }
169
+ throw new Error(
170
+ `Failed to connect to daemon after ${MAX_CONNECT_ATTEMPTS} attempts.
171
+ Check if port ${PORT} is available: lsof -i :${PORT}`
172
+ );
173
+ }
174
+ function setupDaemonConnection(ws) {
175
+ daemonWs = ws;
176
+ ws.on("message", (data) => handleDaemonMessage(data.toString()));
177
+ ws.on("close", () => {
178
+ log("Daemon connection lost, will reconnect on next tool call");
179
+ rejectAllPending("Daemon connection lost");
180
+ daemonWs = void 0;
181
+ });
182
+ ws.on("error", (err) => {
183
+ log(`Daemon WebSocket error: ${err.message}`);
184
+ });
185
+ }
186
+ async function ensureDaemonConnection() {
187
+ if (isDaemonConnected()) return;
188
+ log("Reconnecting to daemon...");
189
+ const ws = await connectWithAutoSpawn();
190
+ setupDaemonConnection(ws);
191
+ }
269
192
  var server = new Server(
270
- { name: "aipex-mcp-bridge", version: "2.1.0" },
193
+ { name: "aipex-mcp-bridge", version: "3.1.0" },
271
194
  { capabilities: { tools: {} } }
272
195
  );
273
196
  server.setRequestHandler(ListToolsRequestSchema, async () => {
@@ -283,7 +206,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
283
206
  };
284
207
  }
285
208
  try {
286
- const result = await proxyClient.sendToolCall(
209
+ await ensureDaemonConnection();
210
+ const result = await sendToolCallToDaemon(
287
211
  name,
288
212
  args ?? {}
289
213
  );
@@ -306,31 +230,27 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
306
230
  }
307
231
  });
308
232
  async function main() {
309
- try {
310
- await proxyClient.connect(PROXY_PORT, EXT_PORT, WS_HOST);
311
- } catch (err) {
312
- log2(
313
- `Warning: ${err instanceof Error ? err.message : String(err)}`
314
- );
315
- log2("Will attempt tool calls anyway (daemon may start later)");
316
- }
233
+ const ws = await connectWithAutoSpawn();
234
+ setupDaemonConnection(ws);
317
235
  const transport = new StdioServerTransport();
318
236
  await server.connect(transport);
319
- log2("AIPex MCP Bridge started (multi-client mode)");
320
- log2(`Connected to daemon at ws://${WS_HOST}:${PROXY_PORT}`);
237
+ log("AIPex MCP Bridge started (stdio \u2192 daemon relay)");
238
+ log(`Connected to daemon at ${DAEMON_URL}`);
321
239
  }
322
240
  process.stdin.on("close", async () => {
323
241
  setTimeout(() => process.exit(0), 5e3);
242
+ rejectAllPending("Bridge shutting down");
243
+ if (daemonWs) daemonWs.close();
324
244
  await server.close();
325
- await proxyClient.close();
326
245
  process.exit(0);
327
246
  });
328
- process.on("SIGINT", async () => {
329
- log2("Shutting down...");
330
- await proxyClient.close();
247
+ process.on("SIGINT", () => {
248
+ log("Shutting down...");
249
+ rejectAllPending("Bridge shutting down");
250
+ if (daemonWs) daemonWs.close();
331
251
  process.exit(0);
332
252
  });
333
253
  main().catch((err) => {
334
- log2(`Fatal error: ${err instanceof Error ? err.message : String(err)}`);
254
+ log(`Fatal error: ${err instanceof Error ? err.message : String(err)}`);
335
255
  process.exit(1);
336
256
  });
package/dist/cli.js CHANGED
@@ -4,8 +4,10 @@ import {
4
4
  } from "./chunk-DHFGE3G7.js";
5
5
 
6
6
  // src/cli.ts
7
- import { spawn } from "child_process";
7
+ import { fork, spawn } from "child_process";
8
8
  import { existsSync } from "fs";
9
+ import { fileURLToPath } from "url";
10
+ import { dirname, join } from "path";
9
11
  import { WebSocket } from "ws";
10
12
  var DEFAULT_WS_URL = "ws://localhost:9223/cli";
11
13
  var ENTRYPOINT_PATH = "/entrypoint.sh";
@@ -76,8 +78,8 @@ Examples:
76
78
  aipex-cli capture_screenshot
77
79
 
78
80
  Environment:
79
- AIPEX_WS_URL WebSocket URL (default: ws://localhost:9223/cli)
80
- AIPEX_CONNECT_TIMEOUT Max ms to wait for bridge+extension (default: 60000)
81
+ AIPEX_WS_URL Daemon WebSocket URL (default: ws://localhost:9223/cli)
82
+ AIPEX_CONNECT_TIMEOUT Max ms to wait for daemon (default: 60000)
81
83
  `);
82
84
  }
83
85
  function printToolList() {
@@ -148,7 +150,7 @@ function coerceValue(value, key, props) {
148
150
  const schema = props[key];
149
151
  const type = schema?.type;
150
152
  switch (type) {
151
- case "number":
153
+ case "number": {
152
154
  const num = Number(value);
153
155
  if (isNaN(num)) {
154
156
  process.stderr.write(`--${key} expects a number, got: ${value}
@@ -156,6 +158,7 @@ function coerceValue(value, key, props) {
156
158
  process.exit(1);
157
159
  }
158
160
  return num;
161
+ }
159
162
  case "boolean":
160
163
  return value === "true" || value === "1";
161
164
  case "array":
@@ -173,12 +176,12 @@ function coerceValue(value, key, props) {
173
176
  }
174
177
  function isRetryableError(msg) {
175
178
  const lower = msg.toLowerCase();
176
- return lower.includes("not connected") || lower.includes("extension is not connected") || lower.includes("no extension");
179
+ return lower.includes("not connected") || lower.includes("extension is not connected") || lower.includes("no extension") || lower.includes("econnrefused") || lower.includes("fetch failed");
177
180
  }
178
181
  function sleep(ms) {
179
182
  return new Promise((r) => setTimeout(r, ms));
180
183
  }
181
- function attemptToolCall(wsUrl, name, args2) {
184
+ function attemptWsToolCall(wsUrl, name, args2) {
182
185
  return new Promise((resolve) => {
183
186
  let settled = false;
184
187
  const ws = new WebSocket(wsUrl);
@@ -243,11 +246,6 @@ function attemptToolCall(wsUrl, name, args2) {
243
246
  return;
244
247
  }
245
248
  const result = response.result;
246
- if (result?.isError && result?.content?.[0]?.text && isRetryableError(result.content[0].text)) {
247
- ws.close();
248
- resolve({ retry: true, code: 1 });
249
- return;
250
- }
251
249
  if (result?.content && Array.isArray(result.content)) {
252
250
  for (const item of result.content) {
253
251
  if (item.type === "text" && item.text) {
@@ -279,20 +277,36 @@ function attemptToolCall(wsUrl, name, args2) {
279
277
  });
280
278
  });
281
279
  }
280
+ function spawnDaemon() {
281
+ const __filename = fileURLToPath(import.meta.url);
282
+ const __dirname = dirname(__filename);
283
+ const daemonPath = join(__dirname, "daemon.js");
284
+ try {
285
+ const child = fork(daemonPath, ["--port", "9223", "--host", "127.0.0.1"], {
286
+ detached: true,
287
+ stdio: "ignore"
288
+ });
289
+ child.unref();
290
+ child.on("error", () => {
291
+ });
292
+ } catch {
293
+ }
294
+ }
282
295
  async function runTool(name, args2) {
283
296
  const wsUrl = process.env.AIPEX_WS_URL ?? DEFAULT_WS_URL;
284
297
  const deadline = Date.now() + MAX_RETRY_TIMEOUT_MS;
285
298
  let backoff = INITIAL_BACKOFF_MS;
286
299
  let attempt = 0;
300
+ let daemonSpawned = false;
287
301
  while (true) {
288
302
  attempt++;
289
- const result = await attemptToolCall(wsUrl, name, args2);
303
+ const result = await attemptWsToolCall(wsUrl, name, args2);
290
304
  if (!result.retry) {
291
305
  process.exit(result.code);
292
306
  }
293
307
  if (Date.now() >= deadline) {
294
308
  process.stderr.write(
295
- `Gave up after ${MAX_RETRY_TIMEOUT_MS / 1e3}s \u2014 bridge or extension not ready at ${wsUrl}
309
+ `Gave up after ${MAX_RETRY_TIMEOUT_MS / 1e3}s \u2014 daemon not ready at ${wsUrl}
296
310
  `
297
311
  );
298
312
  process.exit(1);
@@ -312,14 +326,13 @@ async function runTool(name, args2) {
312
326
  child.on("error", () => {
313
327
  });
314
328
  child.unref();
315
- } else {
316
- process.stderr.write(
317
- `[aipex-cli] ${ENTRYPOINT_PATH} not found, waiting for external start ...
318
- `
319
- );
329
+ } else if (!daemonSpawned) {
330
+ process.stderr.write("[aipex-cli] Spawning daemon...\n");
331
+ spawnDaemon();
332
+ daemonSpawned = true;
320
333
  }
321
334
  process.stderr.write(
322
- `[aipex-cli] Waiting for bridge + extension at ${wsUrl} ...
335
+ `[aipex-cli] Waiting for AIPex daemon + extension ...
323
336
  `
324
337
  );
325
338
  }
package/dist/daemon.js CHANGED
@@ -5,63 +5,26 @@ import {
5
5
 
6
6
  // src/daemon.ts
7
7
  import { createServer } from "http";
8
- import { writeFileSync, unlinkSync, existsSync, readFileSync } from "fs";
9
- import { homedir } from "os";
8
+ import { writeFileSync, unlinkSync } from "fs";
10
9
  import { join } from "path";
11
- import { WebSocketServer, WebSocket } from "ws";
12
- var PID_FILE = join(homedir(), ".aipex-mcp-daemon.pid");
13
- var IDLE_TIMEOUT_MS = 3e4;
14
- var TOOL_CALL_TIMEOUT_MS = 6e4;
15
- var PING_INTERVAL_MS = 15e3;
16
- var PING_TIMEOUT_MS = 5e3;
10
+ import { homedir } from "os";
11
+ import { WebSocket, WebSocketServer } from "ws";
17
12
  var cliArgs = process.argv.slice(2);
18
- if (cliArgs.includes("--help") || cliArgs.includes("-h")) {
19
- process.stderr.write(`
20
- AIPex MCP Daemon \u2014 shared backend for multi-client MCP bridge
21
-
22
- Usage:
23
- aipex-mcp-daemon [--port <port>] [--proxy-port <port>] [--host <host>]
24
-
25
- Options:
26
- --port <port> Extension WebSocket port (default: 9223)
27
- --proxy-port <port> Proxy client WebSocket port (default: 9224)
28
- --host <host> Bind address (default: 127.0.0.1)
29
- --help, -h Show this help message
30
- `);
31
- process.exit(0);
32
- }
33
13
  function getArg(name, fallback) {
34
14
  const idx = cliArgs.indexOf(name);
35
15
  return idx !== -1 && cliArgs[idx + 1] ? cliArgs[idx + 1] : fallback;
36
16
  }
37
- var WS_HOST = getArg("--host", "127.0.0.1");
38
- var EXT_PORT = parseInt(getArg("--port", "9223"), 10);
39
- var PROXY_PORT = parseInt(getArg("--proxy-port", "9224"), 10);
17
+ var PORT = parseInt(getArg("--port", "9223"), 10);
18
+ var HOST = getArg("--host", "127.0.0.1");
19
+ var PID_FILE = join(homedir(), ".aipex-daemon.pid");
20
+ var IDLE_TIMEOUT_MS = 3e4;
21
+ var TOOL_CALL_TIMEOUT_MS = 6e4;
22
+ var PING_INTERVAL_MS = 15e3;
23
+ var PING_TIMEOUT_MS = 5e3;
40
24
  function log(msg) {
41
25
  process.stderr.write(`[aipex-daemon] ${msg}
42
26
  `);
43
27
  }
44
- function writePidFile() {
45
- try {
46
- writeFileSync(PID_FILE, `${process.pid}
47
- ${PROXY_PORT}
48
- `);
49
- } catch {
50
- log(`Warning: could not write PID file ${PID_FILE}`);
51
- }
52
- }
53
- function removePidFile() {
54
- try {
55
- if (existsSync(PID_FILE)) {
56
- const content = readFileSync(PID_FILE, "utf-8").trim();
57
- const pid = parseInt(content.split("\n")[0], 10);
58
- if (pid === process.pid) {
59
- unlinkSync(PID_FILE);
60
- }
61
- }
62
- } catch {
63
- }
64
- }
65
28
  var extensionWs;
66
29
  var nextExtId = 1;
67
30
  var pendingExtCalls = /* @__PURE__ */ new Map();
@@ -73,25 +36,23 @@ function setExtensionSocket(ws) {
73
36
  if (extensionWs && extensionWs.readyState === WebSocket.OPEN) {
74
37
  extensionWs.close();
75
38
  }
76
- rejectAllExtPending("New extension connection replaced previous one");
39
+ rejectAllPendingExt("New extension connection replaced previous one");
77
40
  extensionWs = ws;
78
- ws.on("message", (data) => {
79
- handleExtensionMessage(data.toString());
80
- });
41
+ ws.on("message", (data) => handleExtensionMessage(data.toString()));
81
42
  ws.on("close", () => {
82
43
  if (extensionWs === ws) {
83
44
  log("Extension disconnected");
84
45
  stopExtPing();
85
- rejectAllExtPending("Extension disconnected");
46
+ rejectAllPendingExt("Extension disconnected");
86
47
  extensionWs = void 0;
87
- broadcastStatus();
48
+ resetIdleTimer();
88
49
  }
89
50
  });
90
51
  ws.on("error", (err) => {
91
52
  log(`Extension WebSocket error: ${err.message}`);
92
53
  });
93
54
  startExtPing();
94
- broadcastStatus();
55
+ resetIdleTimer();
95
56
  log("Extension connected");
96
57
  }
97
58
  function handleExtensionMessage(raw) {
@@ -110,29 +71,32 @@ function handleExtensionMessage(raw) {
110
71
  pendingExtCalls.delete(id);
111
72
  const response = {
112
73
  jsonrpc: "2.0",
113
- id: pending.proxyRequestId
74
+ id: pending.bridgeReqId
114
75
  };
115
76
  if (msg.error) {
116
77
  response.error = msg.error;
117
78
  } else {
118
79
  response.result = msg.result;
119
80
  }
120
- if (pending.proxySocket.readyState === WebSocket.OPEN) {
121
- pending.proxySocket.send(JSON.stringify(response));
81
+ if (pending.bridgeSocket.readyState === WebSocket.OPEN) {
82
+ pending.bridgeSocket.send(JSON.stringify(response));
122
83
  }
123
84
  }
124
- function sendToolCallToExtension(toolName, args, proxySocket, proxyRequestId) {
85
+ function forwardToolCall(bridgeSocket, bridgeReqId, toolName, args) {
125
86
  if (!isExtensionConnected()) {
126
- const errResponse = {
87
+ const errResp = {
127
88
  jsonrpc: "2.0",
128
- id: proxyRequestId,
89
+ id: bridgeReqId,
129
90
  error: {
130
91
  code: -1,
131
- message: "AIPex extension is not connected. To connect:\n1. Open Chrome \u2192 AIPex extension \u2192 Options page\n2. Click the Connect button\n3. The extension will connect automatically."
92
+ message: `AIPex extension is not connected. To connect:
93
+ 1. Open Chrome \u2192 AIPex extension \u2192 Options page
94
+ 2. Set WebSocket URL to ws://localhost:${PORT}/extension
95
+ 3. Click Connect`
132
96
  }
133
97
  };
134
- if (proxySocket.readyState === WebSocket.OPEN) {
135
- proxySocket.send(JSON.stringify(errResponse));
98
+ if (bridgeSocket.readyState === WebSocket.OPEN) {
99
+ bridgeSocket.send(JSON.stringify(errResp));
136
100
  }
137
101
  return;
138
102
  }
@@ -147,31 +111,31 @@ function sendToolCallToExtension(toolName, args, proxySocket, proxyRequestId) {
147
111
  const timer = setTimeout(() => {
148
112
  if (pendingExtCalls.has(extId)) {
149
113
  pendingExtCalls.delete(extId);
150
- const errResponse = {
114
+ const timeoutResp = {
151
115
  jsonrpc: "2.0",
152
- id: proxyRequestId,
116
+ id: bridgeReqId,
153
117
  error: {
154
118
  code: -1,
155
119
  message: `Tool '${toolName}' timed out after ${TOOL_CALL_TIMEOUT_MS}ms`
156
120
  }
157
121
  };
158
- if (proxySocket.readyState === WebSocket.OPEN) {
159
- proxySocket.send(JSON.stringify(errResponse));
122
+ if (bridgeSocket.readyState === WebSocket.OPEN) {
123
+ bridgeSocket.send(JSON.stringify(timeoutResp));
160
124
  }
161
125
  }
162
126
  }, TOOL_CALL_TIMEOUT_MS);
163
- pendingExtCalls.set(extId, { proxySocket, proxyRequestId, timer });
127
+ pendingExtCalls.set(extId, { bridgeSocket, bridgeReqId, timer });
164
128
  }
165
- function rejectAllExtPending(reason) {
166
- for (const [id, entry] of pendingExtCalls) {
129
+ function rejectAllPendingExt(reason) {
130
+ for (const [, entry] of pendingExtCalls) {
167
131
  clearTimeout(entry.timer);
168
- const errResponse = {
132
+ const errResp = {
169
133
  jsonrpc: "2.0",
170
- id: entry.proxyRequestId,
134
+ id: entry.bridgeReqId,
171
135
  error: { code: -1, message: reason }
172
136
  };
173
- if (entry.proxySocket.readyState === WebSocket.OPEN) {
174
- entry.proxySocket.send(JSON.stringify(errResponse));
137
+ if (entry.bridgeSocket.readyState === WebSocket.OPEN) {
138
+ entry.bridgeSocket.send(JSON.stringify(errResp));
175
139
  }
176
140
  }
177
141
  pendingExtCalls.clear();
@@ -194,8 +158,8 @@ function startExtPing() {
194
158
  }
195
159
  }, PING_TIMEOUT_MS);
196
160
  pendingExtCalls.set(id, {
197
- proxySocket: null,
198
- proxyRequestId: -1,
161
+ bridgeSocket: extensionWs,
162
+ bridgeReqId: `ping-${id}`,
199
163
  timer
200
164
  });
201
165
  }, PING_INTERVAL_MS);
@@ -206,21 +170,8 @@ function stopExtPing() {
206
170
  extPingInterval = null;
207
171
  }
208
172
  }
209
- var proxyClients = /* @__PURE__ */ new Set();
210
- var idleTimer = null;
211
- function broadcastStatus() {
212
- const statusMsg = JSON.stringify({
213
- jsonrpc: "2.0",
214
- method: "status",
215
- params: { extensionConnected: isExtensionConnected() }
216
- });
217
- for (const client of proxyClients) {
218
- if (client.readyState === WebSocket.OPEN) {
219
- client.send(statusMsg);
220
- }
221
- }
222
- }
223
- function handleProxyMessage(socket, raw) {
173
+ var bridgeClients = /* @__PURE__ */ new Set();
174
+ function handleBridgeMessage(socket, raw) {
224
175
  let msg;
225
176
  try {
226
177
  msg = JSON.parse(raw);
@@ -229,21 +180,17 @@ function handleProxyMessage(socket, raw) {
229
180
  }
230
181
  const id = msg.id;
231
182
  const method = msg.method;
232
- if (method === "tools/list") {
233
- socket.send(
234
- JSON.stringify({
235
- jsonrpc: "2.0",
236
- id,
237
- result: { tools: toolSchemas }
238
- })
239
- );
240
- return;
241
- }
242
183
  if (method === "tools/call") {
243
184
  const params = msg.params ?? {};
244
185
  const name = params.name;
245
186
  const args = params.arguments ?? {};
246
- sendToolCallToExtension(name, args, socket, id ?? 0);
187
+ forwardToolCall(socket, id ?? 0, name, args);
188
+ return;
189
+ }
190
+ if (method === "tools/list") {
191
+ socket.send(
192
+ JSON.stringify({ jsonrpc: "2.0", id, result: { tools: toolSchemas } })
193
+ );
247
194
  return;
248
195
  }
249
196
  if (method === "ping") {
@@ -255,137 +202,138 @@ function handleProxyMessage(socket, raw) {
255
202
  JSON.stringify({
256
203
  jsonrpc: "2.0",
257
204
  id,
258
- result: { extensionConnected: isExtensionConnected() }
205
+ result: {
206
+ extensionConnected: isExtensionConnected(),
207
+ bridgeClients: bridgeClients.size
208
+ }
259
209
  })
260
210
  );
261
211
  return;
262
212
  }
263
213
  }
264
- function cleanupPendingForProxy(socket) {
265
- for (const [id, entry] of pendingExtCalls) {
266
- if (entry.proxySocket === socket) {
267
- clearTimeout(entry.timer);
268
- pendingExtCalls.delete(id);
269
- }
270
- }
271
- }
272
- function startIdleTimer() {
273
- stopIdleTimer();
274
- idleTimer = setTimeout(() => {
275
- if (proxyClients.size === 0) {
276
- log(`No proxy clients connected for ${IDLE_TIMEOUT_MS / 1e3}s, exiting`);
277
- shutdown();
278
- }
279
- }, IDLE_TIMEOUT_MS);
214
+ function handleCliMessage(socket, raw) {
215
+ handleBridgeMessage(socket, raw);
280
216
  }
281
- function stopIdleTimer() {
217
+ var idleTimer = null;
218
+ function resetIdleTimer() {
282
219
  if (idleTimer) {
283
220
  clearTimeout(idleTimer);
284
221
  idleTimer = null;
285
222
  }
223
+ if (bridgeClients.size === 0 && !isExtensionConnected()) {
224
+ idleTimer = setTimeout(() => {
225
+ log(`No connections for ${IDLE_TIMEOUT_MS / 1e3}s, shutting down`);
226
+ shutdown();
227
+ }, IDLE_TIMEOUT_MS);
228
+ }
286
229
  }
287
- var extHttpServer = createServer();
288
- var extWss = new WebSocketServer({ server: extHttpServer });
289
- extWss.on("connection", (socket, req) => {
290
- const addr = req.socket.remoteAddress ?? "unknown";
230
+ var httpServer = createServer((req, res) => {
231
+ if (req.url === "/health" && req.method === "GET") {
232
+ res.writeHead(200, { "Content-Type": "application/json" });
233
+ res.end(
234
+ JSON.stringify({
235
+ status: "ok",
236
+ extensionConnected: isExtensionConnected(),
237
+ bridgeClients: bridgeClients.size,
238
+ version: "3.1.0"
239
+ })
240
+ );
241
+ return;
242
+ }
243
+ res.writeHead(404).end("Not found");
244
+ });
245
+ var extensionWss = new WebSocketServer({ noServer: true });
246
+ var bridgeWss = new WebSocketServer({ noServer: true });
247
+ var cliWss = new WebSocketServer({ noServer: true });
248
+ httpServer.on("upgrade", (req, socket, head) => {
291
249
  const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
292
- if (pathname === "/cli") {
293
- log(`CLI client connected from ${addr}`);
294
- socket.on("message", (data) => {
295
- handleProxyMessage(socket, data.toString());
250
+ if (pathname === "/extension" || pathname === "/") {
251
+ extensionWss.handleUpgrade(req, socket, head, (ws) => {
252
+ extensionWss.emit("connection", ws, req);
296
253
  });
297
- return;
254
+ } else if (pathname === "/bridge") {
255
+ bridgeWss.handleUpgrade(req, socket, head, (ws) => {
256
+ bridgeWss.emit("connection", ws, req);
257
+ });
258
+ } else if (pathname === "/cli") {
259
+ cliWss.handleUpgrade(req, socket, head, (ws) => {
260
+ cliWss.emit("connection", ws, req);
261
+ });
262
+ } else {
263
+ socket.destroy();
298
264
  }
265
+ });
266
+ extensionWss.on("connection", (socket, req) => {
267
+ const addr = req.socket.remoteAddress ?? "unknown";
299
268
  log(`Extension connected from ${addr}`);
300
269
  setExtensionSocket(socket);
301
270
  });
302
- extWss.on("error", (err) => {
303
- log(`Extension WebSocket server error: ${err.message}`);
304
- });
305
- var proxyHttpServer = createServer();
306
- var proxyWss = new WebSocketServer({ server: proxyHttpServer });
307
- proxyWss.on("connection", (socket, req) => {
308
- const addr = req.socket.remoteAddress ?? "unknown";
309
- log(`Proxy client connected from ${addr} (total: ${proxyClients.size + 1})`);
310
- proxyClients.add(socket);
311
- stopIdleTimer();
312
- const statusMsg = JSON.stringify({
313
- jsonrpc: "2.0",
314
- method: "status",
315
- params: { extensionConnected: isExtensionConnected() }
316
- });
317
- socket.send(statusMsg);
318
- socket.on("message", (data) => {
319
- handleProxyMessage(socket, data.toString());
320
- });
271
+ bridgeWss.on("connection", (socket) => {
272
+ bridgeClients.add(socket);
273
+ resetIdleTimer();
274
+ log(`Bridge client connected (total: ${bridgeClients.size})`);
275
+ socket.on("message", (data) => handleBridgeMessage(socket, data.toString()));
321
276
  socket.on("close", () => {
322
- proxyClients.delete(socket);
323
- cleanupPendingForProxy(socket);
324
- log(`Proxy client disconnected (remaining: ${proxyClients.size})`);
325
- if (proxyClients.size === 0) {
326
- startIdleTimer();
327
- }
277
+ bridgeClients.delete(socket);
278
+ resetIdleTimer();
279
+ log(`Bridge client disconnected (total: ${bridgeClients.size})`);
328
280
  });
329
281
  socket.on("error", (err) => {
330
- log(`Proxy client error: ${err.message}`);
282
+ log(`Bridge client error: ${err.message}`);
331
283
  });
332
284
  });
333
- proxyWss.on("error", (err) => {
334
- log(`Proxy WebSocket server error: ${err.message}`);
285
+ cliWss.on("connection", (socket) => {
286
+ bridgeClients.add(socket);
287
+ resetIdleTimer();
288
+ socket.on("message", (data) => handleCliMessage(socket, data.toString()));
289
+ socket.on("close", () => {
290
+ bridgeClients.delete(socket);
291
+ resetIdleTimer();
292
+ });
293
+ });
294
+ function writePidFile() {
295
+ try {
296
+ writeFileSync(PID_FILE, String(process.pid));
297
+ } catch {
298
+ }
299
+ }
300
+ function removePidFile() {
301
+ try {
302
+ unlinkSync(PID_FILE);
303
+ } catch {
304
+ }
305
+ }
306
+ httpServer.listen(PORT, HOST, () => {
307
+ writePidFile();
308
+ log(`AIPex MCP Daemon started (v3.1.0) pid=${process.pid}`);
309
+ log(`Extension WS: ws://${HOST}:${PORT}/extension`);
310
+ log(`Bridge WS: ws://${HOST}:${PORT}/bridge`);
311
+ log(`CLI WS: ws://${HOST}:${PORT}/cli`);
312
+ log(`Health: http://${HOST}:${PORT}/health`);
313
+ resetIdleTimer();
314
+ });
315
+ httpServer.on("error", (err) => {
316
+ if (err.code === "EADDRINUSE") {
317
+ log(`Port ${PORT} already in use \u2014 another daemon is likely running`);
318
+ process.exit(0);
319
+ }
320
+ log(`Server error: ${err.message}`);
321
+ process.exit(1);
335
322
  });
336
323
  function shutdown() {
337
- log("Shutting down daemon...");
338
324
  stopExtPing();
339
- stopIdleTimer();
340
- rejectAllExtPending("Daemon shutting down");
325
+ rejectAllPendingExt("Daemon shutting down");
341
326
  if (extensionWs) {
342
327
  extensionWs.close();
343
328
  extensionWs = void 0;
344
329
  }
345
- for (const client of proxyClients) {
346
- client.close();
347
- }
348
- proxyClients.clear();
349
- extWss.close();
350
- proxyWss.close();
351
- extHttpServer.close();
352
- proxyHttpServer.close();
330
+ extensionWss.close();
331
+ bridgeWss.close();
332
+ cliWss.close();
333
+ httpServer.close();
353
334
  removePidFile();
335
+ if (idleTimer) clearTimeout(idleTimer);
354
336
  process.exit(0);
355
337
  }
356
338
  process.on("SIGINT", shutdown);
357
339
  process.on("SIGTERM", shutdown);
358
- async function main() {
359
- await new Promise((resolve, reject) => {
360
- extHttpServer.on("error", (err) => {
361
- if (err.code === "EADDRINUSE") {
362
- log(`Extension port ${EXT_PORT} is already in use. Another daemon may be running.`);
363
- process.exit(1);
364
- }
365
- reject(err);
366
- });
367
- extHttpServer.listen(EXT_PORT, WS_HOST, resolve);
368
- });
369
- await new Promise((resolve, reject) => {
370
- proxyHttpServer.on("error", (err) => {
371
- if (err.code === "EADDRINUSE") {
372
- log(`Proxy port ${PROXY_PORT} is already in use. Another daemon may be running.`);
373
- extHttpServer.close();
374
- process.exit(1);
375
- }
376
- reject(err);
377
- });
378
- proxyHttpServer.listen(PROXY_PORT, WS_HOST, resolve);
379
- });
380
- writePidFile();
381
- startIdleTimer();
382
- log(`AIPex MCP Daemon started (PID: ${process.pid})`);
383
- log(`Extension WebSocket: ws://${WS_HOST}:${EXT_PORT}`);
384
- log(`Proxy WebSocket: ws://${WS_HOST}:${PROXY_PORT}`);
385
- log(`Waiting for connections...`);
386
- }
387
- main().catch((err) => {
388
- log(`Fatal error: ${err instanceof Error ? err.message : String(err)}`);
389
- removePidFile();
390
- process.exit(1);
391
- });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aipex-mcp-bridge",
3
- "version": "2.1.0",
4
- "description": "MCP bridge that connects AI agents (Cursor, Claude, VS Code Copilot, etc.) to the AIPex browser extension via WebSocket",
3
+ "version": "3.1.0",
4
+ "description": "MCP bridge that connects AI agents (Cursor, Claude Code, VS Code Copilot, etc.) to the AIPex browser extension. Auto-spawns a shared daemon to support multiple simultaneous clients.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "aipex-mcp-bridge": "./dist/bridge.js",
@@ -14,10 +14,11 @@
14
14
  ],
15
15
  "scripts": {
16
16
  "build": "tsup",
17
- "dev": "tsx src/bridge.ts"
17
+ "dev": "tsx src/bridge.ts",
18
+ "dev:daemon": "tsx src/daemon.ts"
18
19
  },
19
20
  "dependencies": {
20
- "@modelcontextprotocol/sdk": "^1.8.0",
21
+ "@modelcontextprotocol/sdk": "^1.28.0",
21
22
  "ws": "^8.18.0"
22
23
  },
23
24
  "devDependencies": {