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 +55 -80
- package/dist/bridge.js +169 -249
- package/dist/cli.js +32 -19
- package/dist/daemon.js +150 -202
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -1,44 +1,39 @@
|
|
|
1
1
|
# aipex-mcp-bridge
|
|
2
2
|
|
|
3
|
-
MCP
|
|
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
|
-
|
|
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
|
|
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.
|
|
20
|
+
### 1. Start the server
|
|
18
21
|
|
|
19
|
-
|
|
22
|
+
```bash
|
|
23
|
+
npx aipex-mcp-server
|
|
24
|
+
```
|
|
20
25
|
|
|
21
|
-
|
|
26
|
+
The server stays running and handles all AI agent connections.
|
|
22
27
|
|
|
23
|
-
|
|
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
|
-
**
|
|
30
|
+
**Cursor** (`.cursor/mcp.json` or `~/.cursor/mcp.json`):
|
|
35
31
|
|
|
36
32
|
```json
|
|
37
33
|
{
|
|
38
34
|
"mcpServers": {
|
|
39
35
|
"aipex-browser": {
|
|
40
|
-
"
|
|
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
|
|
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
|
-
"
|
|
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
|
-
###
|
|
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
|
|
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-
|
|
71
|
+
npx aipex-mcp-server [--port <port>] [--host <host>]
|
|
91
72
|
```
|
|
92
73
|
|
|
93
|
-
| Option
|
|
94
|
-
|
|
|
95
|
-
| `--port <port>`
|
|
96
|
-
| `--host <host>`
|
|
97
|
-
| `--help`, `-h`
|
|
98
|
-
| `--version`, `-v
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
-
###
|
|
125
|
+
### Environment Variables
|
|
149
126
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
146
|
+
| 9223 | MCP Server (HTTP + WS) |
|
|
173
147
|
| 5900 | VNC |
|
|
174
148
|
| 6080 | noVNC (web-based) |
|
|
175
149
|
|
|
176
|
-
|
|
150
|
+
## Migration from v2.x
|
|
177
151
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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>] [--
|
|
23
|
+
npx aipex-mcp-bridge [--port <port>] [--host <host>]
|
|
232
24
|
|
|
233
25
|
Options:
|
|
234
|
-
--port <port>
|
|
235
|
-
--
|
|
236
|
-
--
|
|
237
|
-
--
|
|
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
|
-
|
|
241
|
-
|
|
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,
|
|
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
|
|
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
|
|
257
|
-
var
|
|
258
|
-
var
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
310
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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",
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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
|
|
80
|
-
AIPEX_CONNECT_TIMEOUT
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
9
|
-
import { homedir } from "os";
|
|
8
|
+
import { writeFileSync, unlinkSync } from "fs";
|
|
10
9
|
import { join } from "path";
|
|
11
|
-
import {
|
|
12
|
-
|
|
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
|
|
38
|
-
var
|
|
39
|
-
var
|
|
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
|
-
|
|
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
|
-
|
|
46
|
+
rejectAllPendingExt("Extension disconnected");
|
|
86
47
|
extensionWs = void 0;
|
|
87
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
121
|
-
pending.
|
|
81
|
+
if (pending.bridgeSocket.readyState === WebSocket.OPEN) {
|
|
82
|
+
pending.bridgeSocket.send(JSON.stringify(response));
|
|
122
83
|
}
|
|
123
84
|
}
|
|
124
|
-
function
|
|
85
|
+
function forwardToolCall(bridgeSocket, bridgeReqId, toolName, args) {
|
|
125
86
|
if (!isExtensionConnected()) {
|
|
126
|
-
const
|
|
87
|
+
const errResp = {
|
|
127
88
|
jsonrpc: "2.0",
|
|
128
|
-
id:
|
|
89
|
+
id: bridgeReqId,
|
|
129
90
|
error: {
|
|
130
91
|
code: -1,
|
|
131
|
-
message:
|
|
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 (
|
|
135
|
-
|
|
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
|
|
114
|
+
const timeoutResp = {
|
|
151
115
|
jsonrpc: "2.0",
|
|
152
|
-
id:
|
|
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 (
|
|
159
|
-
|
|
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, {
|
|
127
|
+
pendingExtCalls.set(extId, { bridgeSocket, bridgeReqId, timer });
|
|
164
128
|
}
|
|
165
|
-
function
|
|
166
|
-
for (const [
|
|
129
|
+
function rejectAllPendingExt(reason) {
|
|
130
|
+
for (const [, entry] of pendingExtCalls) {
|
|
167
131
|
clearTimeout(entry.timer);
|
|
168
|
-
const
|
|
132
|
+
const errResp = {
|
|
169
133
|
jsonrpc: "2.0",
|
|
170
|
-
id: entry.
|
|
134
|
+
id: entry.bridgeReqId,
|
|
171
135
|
error: { code: -1, message: reason }
|
|
172
136
|
};
|
|
173
|
-
if (entry.
|
|
174
|
-
entry.
|
|
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
|
-
|
|
198
|
-
|
|
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
|
|
210
|
-
|
|
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
|
-
|
|
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: {
|
|
205
|
+
result: {
|
|
206
|
+
extensionConnected: isExtensionConnected(),
|
|
207
|
+
bridgeClients: bridgeClients.size
|
|
208
|
+
}
|
|
259
209
|
})
|
|
260
210
|
);
|
|
261
211
|
return;
|
|
262
212
|
}
|
|
263
213
|
}
|
|
264
|
-
function
|
|
265
|
-
|
|
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
|
-
|
|
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
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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 === "/
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
log(`
|
|
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(`
|
|
282
|
+
log(`Bridge client error: ${err.message}`);
|
|
331
283
|
});
|
|
332
284
|
});
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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": "
|
|
4
|
-
"description": "MCP bridge that connects AI agents (Cursor, Claude, VS Code Copilot, etc.) to the AIPex browser extension
|
|
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.
|
|
21
|
+
"@modelcontextprotocol/sdk": "^1.28.0",
|
|
21
22
|
"ws": "^8.18.0"
|
|
22
23
|
},
|
|
23
24
|
"devDependencies": {
|