From 9b267acb34610cdffaac063140ed2a11bcb210e6 Mon Sep 17 00:00:00 2001 From: ropzislaw <1710085142@qq.com> Date: Sun, 29 Mar 2026 00:14:17 +0800 Subject: [PATCH] feat: update MCP bridge to support multiple clients and enhance functionality - Bumped version to 3.1.0 and updated the description to reflect new capabilities. - Introduced a shared daemon for handling multiple simultaneous clients, improving performance. - Added new CLI commands and updated the README for better user guidance. - Enhanced the package.json to include new dependencies and scripts for the daemon and CLI. - Refactored the bridge architecture to utilize StreamableHTTP for improved communication with AI agents. These changes significantly enhance the usability and functionality of the MCP bridge. --- mcp-bridge/.npmrc | 1 + mcp-bridge/README.md | 152 ++++--- mcp-bridge/package.json | 12 +- mcp-bridge/pnpm-lock.yaml | 736 ++++++++++++++++++++++++++++++++ mcp-bridge/src/bridge.ts | 576 ++++++++++++------------- mcp-bridge/src/cli.ts | 398 ++++++++++++++++++ mcp-bridge/src/daemon.ts | 441 ++++++++++++++++++++ mcp-bridge/src/tool-schemas.ts | 738 +++++++++++++++++++++++++++++++++ mcp-bridge/tsup.config.ts | 10 +- 9 files changed, 2721 insertions(+), 343 deletions(-) create mode 100644 mcp-bridge/.npmrc create mode 100644 mcp-bridge/src/cli.ts create mode 100644 mcp-bridge/src/daemon.ts create mode 100644 mcp-bridge/src/tool-schemas.ts diff --git a/mcp-bridge/.npmrc b/mcp-bridge/.npmrc new file mode 100644 index 0000000..38f11c6 --- /dev/null +++ b/mcp-bridge/.npmrc @@ -0,0 +1 @@ +registry=https://registry.npmjs.org diff --git a/mcp-bridge/README.md b/mcp-bridge/README.md index fe32a00..a196dfa 100644 --- a/mcp-bridge/README.md +++ b/mcp-bridge/README.md @@ -1,44 +1,40 @@ # aipex-mcp-bridge -MCP bridge that connects AI agents to the [AIPex](https://aipex.ai) browser extension via WebSocket. - -Works with **any** MCP client that supports stdio transport — Cursor, Claude Desktop, Claude Code, VS Code Copilot, Windsurf, Zed, and more. +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. ## How it works ``` -AI Agent (MCP client) ──stdio──▶ aipex-mcp-bridge ──WebSocket──▶ AIPex Chrome Extension +Cursor ──HTTP POST /mcp──┐ +Claude Code ──HTTP POST /mcp──┤── aipex-mcp-server ──WebSocket──▶ AIPex Chrome Extension +VS Code ──HTTP POST /mcp──┘ ``` -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. +The server runs on `localhost:9223` and provides: + +- **`/mcp`** — StreamableHTTP endpoint for MCP clients +- **`/extension`** — WebSocket endpoint for the AIPex Chrome extension +- **`/health`** — Health check endpoint ## Quick start -### 1. Configure your AI agent +### 1. Start the server -Add the following to your agent's MCP configuration: - -**Cursor** (`.cursor/mcp.json`): - -```json -{ - "mcpServers": { - "aipex-browser": { - "command": "npx", - "args": ["-y", "aipex-mcp-bridge"] - } - } -} +```bash +npx aipex-mcp-server ``` -**Claude Desktop** (`claude_desktop_config.json`): +The server stays running and handles all AI agent connections. + +### 2. Configure your AI agent + +**Cursor** (`.cursor/mcp.json` or `~/.cursor/mcp.json`): ```json { "mcpServers": { "aipex-browser": { - "command": "npx", - "args": ["-y", "aipex-mcp-bridge"] + "url": "http://localhost:9223/mcp" } } } @@ -47,7 +43,7 @@ Add the following to your agent's MCP configuration: **Claude Code**: ```bash -claude mcp add aipex-browser -- npx -y aipex-mcp-bridge +claude mcp add --transport http aipex-browser http://localhost:9223/mcp ``` **VS Code Copilot** (`.vscode/mcp.json`): @@ -56,63 +52,119 @@ claude mcp add aipex-browser -- npx -y aipex-mcp-bridge { "servers": { "aipex-browser": { - "command": "npx", - "args": ["-y", "aipex-mcp-bridge"] + "url": "http://localhost:9223/mcp" } } } ``` -**Windsurf** (`mcp_config.json`): - -```json -{ - "mcpServers": { - "aipex-browser": { - "command": "npx", - "args": ["-y", "aipex-mcp-bridge"] - } - } -} -``` - -### 2. Connect AIPex extension +### 3. Connect AIPex extension 1. Open Chrome → AIPex extension → Options page -2. Set WebSocket URL to `ws://localhost:9223` +2. Set WebSocket URL to `ws://localhost:9223/extension` 3. Click **Connect** -Your AI agent can now control the browser through AIPex. +Your AI agents can now control the browser through AIPex — all simultaneously. ## Options ``` -npx aipex-mcp-bridge [--port ] +npx aipex-mcp-server [--port ] [--host ] ``` -| Option | Default | Description | -| ----------------- | ------- | ---------------------------------- | -| `--port ` | `9223` | WebSocket port for AIPex extension | -| `--help`, `-h` | | Show help message | -| `--version`, `-v` | | Show version | +| Option | Default | Description | +| ----------------- | ----------- | ----------------------------------------------------------- | +| `--port ` | `9223` | Server port | +| `--host ` | `127.0.0.1` | Bind address (`0.0.0.0` to allow remote/Docker connections) | +| `--help`, `-h` | | Show help message | +| `--version`, `-v` | | Show version | -### Custom port example +--- + +## Stdio Bridge (backward compatibility) + +For MCP clients that only support stdio transport, a thin bridge is included: ```json { "mcpServers": { "aipex-browser": { "command": "npx", - "args": ["-y", "aipex-mcp-bridge", "--port", "8080"] + "args": ["-y", "aipex-mcp-bridge"] } } } ``` +The stdio bridge forwards tool calls to the HTTP server at `http://localhost:9223/mcp`. The server must be running separately. + +--- + +## AIPex CLI + +Command-line tool for controlling the browser directly from the terminal. + +### Usage + +```bash +aipex-cli [--param value ...] +aipex-cli --list # List all tools +aipex-cli --help # Show tool parameters +aipex-cli --json '{"name":"...","arguments":{...}}' # Raw JSON +``` + +### Examples + +```bash +aipex-cli get_all_tabs +aipex-cli create_new_tab --url https://example.com +aipex-cli search_elements --tabId 123 --query "button*" +aipex-cli click --tabId 123 --uid btn-42 +aipex-cli capture_screenshot +``` + +### Environment Variables + +| Variable | Default | Description | +| ----------------------- | --------------------------- | ---------------------- | +| `AIPEX_SERVER_URL` | `http://localhost:9223/mcp` | HTTP server URL | +| `AIPEX_WS_URL` | `ws://localhost:9223/cli` | WebSocket fallback URL | +| `AIPEX_CONNECT_TIMEOUT` | `60000` | Max ms to wait | + +--- + +## Docker Image + +```bash +docker pull butterman2/aipex-browser:latest +docker run -d --name aipex --shm-size=2g \ + -p 9223:9223 -p 5900:5900 -p 6080:6080 \ + butterman2/aipex-browser:latest +``` + +| Port | Service | +| ---- | ---------------------- | +| 9223 | MCP Server (HTTP + WS) | +| 5900 | VNC | +| 6080 | noVNC (web-based) | + +## Migration from v2.x + +v3.0 replaces the daemon+proxy architecture with a single HTTP server: + +| v2.x (daemon) | v3.0 (HTTP server) | +| ------------------------------------------- | ----------------------------------------------------- | +| `npx aipex-mcp-bridge` (stdio per IDE) | `npx aipex-mcp-server` (one server) | +| Each IDE spawns its own bridge process | All IDEs connect to one HTTP endpoint | +| Daemon with PID files, idle timeout | Standard HTTP server, no background process | +| Extension connects to `ws://localhost:9223` | Extension connects to `ws://localhost:9223/extension` | + +**Breaking change**: The AIPex extension WebSocket URL changed from `ws://localhost:9223` to `ws://localhost:9223/extension`. Update the URL in AIPex extension Options. + ## Requirements - Node.js >= 18 -- AIPex Chrome extension installed +- AIPex Chrome extension installed (not needed for Docker image) ## License diff --git a/mcp-bridge/package.json b/mcp-bridge/package.json index ddc1571..7b7d8c3 100644 --- a/mcp-bridge/package.json +++ b/mcp-bridge/package.json @@ -1,10 +1,12 @@ { "name": "aipex-mcp-bridge", - "version": "1.0.0", - "description": "MCP bridge that connects AI agents (Cursor, Claude, VS Code Copilot, etc.) to the AIPex browser extension via WebSocket", + "version": "3.1.0", + "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.", "type": "module", "bin": { - "aipex-mcp-bridge": "./dist/bridge.js" + "aipex-mcp-bridge": "./dist/bridge.js", + "aipex-mcp-daemon": "./dist/daemon.js", + "aipex-cli": "./dist/cli.js" }, "files": [ "dist", @@ -12,9 +14,11 @@ ], "scripts": { "build": "tsup", - "dev": "tsx src/bridge.ts" + "dev": "tsx src/bridge.ts", + "dev:daemon": "tsx src/daemon.ts" }, "dependencies": { + "@modelcontextprotocol/sdk": "^1.28.0", "ws": "^8.18.0" }, "devDependencies": { diff --git a/mcp-bridge/pnpm-lock.yaml b/mcp-bridge/pnpm-lock.yaml index 043776c..e6781f3 100644 --- a/mcp-bridge/pnpm-lock.yaml +++ b/mcp-bridge/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.28.0 + version: 1.28.0(zod@4.3.6) ws: specifier: ^8.18.0 version: 8.19.0 @@ -186,6 +189,12 @@ packages: cpu: [x64] os: [win32] + '@hono/node-server@1.19.11': + resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -199,6 +208,16 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@modelcontextprotocol/sdk@1.28.0': + resolution: {integrity: sha512-gmloF+i+flI8ouQK7MWW4mOwuMh4RePBuPFAEPC6+pdqyWOUMDOixb6qZ69owLJpz6XmyllCouc4t8YWO+E2Nw==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@rollup/rollup-android-arm-eabi@4.59.0': resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} cpu: [arm] @@ -333,24 +352,55 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} hasBin: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} peerDependencies: esbuild: '>=0.18' + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -366,6 +416,30 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -375,11 +449,69 @@ packages: supports-color: optional: true + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} hasBin: true + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + express-rate-limit@8.3.0: + resolution: {integrity: sha512-KJzBawY6fB9FiZGdE/0aftepZ91YlaGIrV8vgblRM3J8X+dHx/aiowJWwkx6LIGyuqGiANsjSwwrbb8mifOJ4Q==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -389,21 +521,94 @@ packages: picomatch: optional: true + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-tsconfig@4.13.6: resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hono@4.12.5: + resolution: {integrity: sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==} + engines: {node: '>=16.9.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jose@6.2.0: + resolution: {integrity: sha512-xsfE1TcSCbUdo6U07tR0mvhg0flGxU8tPLbF03mirl2ukGQENhUg4ubGYQnhVH0b5stLlPM+WOqDkEl1R1y5sQ==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -418,6 +623,26 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} @@ -427,10 +652,36 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -445,6 +696,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -466,10 +721,30 @@ packages: yaml: optional: true + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.15.0: + resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -482,10 +757,56 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + source-map@0.7.6: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -505,6 +826,10 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -536,6 +861,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -547,6 +876,22 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.19.0: resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} engines: {node: '>=10.0.0'} @@ -559,6 +904,14 @@ packages: utf-8-validate: optional: true + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + peerDependencies: + zod: ^3.25 || ^4 + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + snapshots: '@esbuild/aix-ppc64@0.27.3': @@ -639,6 +992,10 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true + '@hono/node-server@1.19.11(hono@4.12.5)': + dependencies: + hono: 4.12.5 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -653,6 +1010,28 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.11(hono@4.12.5) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.3.0(express@5.2.1) + hono: 4.12.5 + jose: 6.2.0 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.1(zod@4.3.6) + transitivePeerDependencies: + - supports-color + '@rollup/rollup-android-arm-eabi@4.59.0': optional: true @@ -738,17 +1117,59 @@ snapshots: dependencies: '@types/node': 22.19.13 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn@8.16.0: {} + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + any-promise@1.3.0: {} + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.0 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + bundle-require@5.1.0(esbuild@0.27.3): dependencies: esbuild: 0.27.3 load-tsconfig: 0.2.5 + bytes@3.1.2: {} + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -759,10 +1180,49 @@ snapshots: consola@3.4.2: {} + content-disposition@1.0.1: {} + + content-type@1.0.5: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + debug@4.4.3: dependencies: ms: 2.1.3 + depd@2.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + esbuild@0.27.3: optionalDependencies: '@esbuild/aix-ppc64': 0.27.3 @@ -792,25 +1252,150 @@ snapshots: '@esbuild/win32-ia32': 0.27.3 '@esbuild/win32-x64': 0.27.3 + escape-html@1.0.3: {} + + etag@1.8.1: {} + + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + + express-rate-limit@8.3.0(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.1.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.0: {} + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + fix-dts-default-cjs-exports@1.0.1: dependencies: magic-string: 0.30.21 mlly: 1.8.0 rollup: 4.59.0 + forwarded@0.2.0: {} + + fresh@2.0.0: {} + fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + get-tsconfig@4.13.6: dependencies: resolve-pkg-maps: 1.0.0 + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hono@4.12.5: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ip-address@10.1.0: {} + + ipaddr.js@1.9.1: {} + + is-promise@4.0.0: {} + + isexe@2.0.0: {} + + jose@6.2.0: {} + joycon@3.1.1: {} + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -821,6 +1406,18 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mlly@1.8.0: dependencies: acorn: 8.16.0 @@ -836,8 +1433,26 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + negotiator@1.0.0: {} + object-assign@4.1.1: {} + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-key@3.1.1: {} + + path-to-regexp@8.3.0: {} + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -846,6 +1461,8 @@ snapshots: pirates@4.0.7: {} + pkce-challenge@5.0.1: {} + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -858,8 +1475,28 @@ snapshots: optionalDependencies: tsx: 4.21.0 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.15.0: + dependencies: + side-channel: 1.1.0 + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + readdirp@4.1.2: {} + require-from-string@2.0.2: {} + resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -895,8 +1532,83 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + + safer-buffer@2.1.2: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + source-map@0.7.6: {} + statuses@2.0.2: {} + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -922,6 +1634,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + toidentifier@1.0.1: {} + tree-kill@1.2.2: {} ts-interface-checker@0.1.13: {} @@ -960,10 +1674,32 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + typescript@5.9.3: {} ufo@1.6.3: {} undici-types@6.21.0: {} + unpipe@1.0.0: {} + + vary@1.1.2: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wrappy@1.0.2: {} + ws@8.19.0: {} + + zod-to-json-schema@3.25.1(zod@4.3.6): + dependencies: + zod: 4.3.6 + + zod@4.3.6: {} diff --git a/mcp-bridge/src/bridge.ts b/mcp-bridge/src/bridge.ts index a5cb085..aef9d7e 100644 --- a/mcp-bridge/src/bridge.ts +++ b/mcp-bridge/src/bridge.ts @@ -1,355 +1,363 @@ /** * AIPex MCP Bridge * - * A stdio MCP server that bridges AI agents to the AIPex Chrome extension via WebSocket. + * A stdio MCP server that auto-starts a shared daemon and relays tool calls + * to the AIPex Chrome extension through it. * - * Agent (MCP client) ──stdio──▶ this bridge ──WebSocket──▶ AIPex extension (MCP server) + * Architecture: + * + * IDE ──stdio──▶ this bridge ──WS /bridge──▶ daemon ──WS /extension──▶ AIPex extension + * + * On startup: + * 1. Try connecting to existing daemon at ws://localhost:/bridge + * 2. If no daemon running, spawn one as a detached background process + * 3. Retry connection with backoff + * 4. Forward all tool calls over WebSocket + * + * Multiple bridge instances share one daemon (multi-client support). * * Usage: * npx aipex-mcp-bridge [--port 9223] - * - * Works with any MCP client that supports stdio transport: - * - Cursor, Claude Desktop, Claude Code, VS Code Copilot, Windsurf, Zed, etc. */ -import { createServer } from "node:http"; -import { createInterface } from "node:readline"; -import { WebSocket, WebSocketServer } from "ws"; +import { fork } from "node:child_process" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" +import { + CallToolRequestSchema, + ListToolsRequestSchema +} from "@modelcontextprotocol/sdk/types.js" +import { WebSocket } from "ws" + +import { toolSchemas } from "./tool-schemas.js" // ── CLI args ──────────────────────────────────────────────────────────────── -const cliArgs = process.argv.slice(2); +const cliArgs = process.argv.slice(2) if (cliArgs.includes("--help") || cliArgs.includes("-h")) { process.stderr.write(` AIPex MCP Bridge — connect AI agents to AIPex browser extension Usage: - npx aipex-mcp-bridge [--port ] + npx aipex-mcp-bridge [--port ] [--host ] Options: - --port WebSocket port for AIPex extension (default: 9223) + --port Daemon port (default: 9223) + --host Daemon host (default: 127.0.0.1) --help, -h Show this help message --version, -v Show version -After starting, open AIPex extension Options and connect to: - ws://localhost: -`); - process.exit(0); +The bridge auto-starts a background daemon if one isn't already running. +Multiple IDE instances (Cursor, Claude Code) can run simultaneously. + +After starting, connect AIPex extension → Options → ws://localhost:/extension +`) + process.exit(0) } if (cliArgs.includes("--version") || cliArgs.includes("-v")) { - process.stderr.write("aipex-mcp-bridge 1.0.0\n"); - process.exit(0); + process.stderr.write("aipex-mcp-bridge 3.1.0\n") + process.exit(0) } -const portIdx = cliArgs.indexOf("--port"); -const WS_PORT = portIdx !== -1 ? parseInt(cliArgs[portIdx + 1], 10) : 9223; - -if (Number.isNaN(WS_PORT) || WS_PORT < 1 || WS_PORT > 65535) { - process.stderr.write(`Invalid port number. Must be between 1 and 65535.\n`); - process.exit(1); +function getArg(name: string, fallback: string): string { + const idx = cliArgs.indexOf(name) + return idx !== -1 && cliArgs[idx + 1] ? cliArgs[idx + 1] : fallback } -// ── Logging (stderr only — stdout is reserved for MCP protocol) ───────────── +const PORT = parseInt(getArg("--port", "9223"), 10) +const HOST = getArg("--host", "127.0.0.1") +const DAEMON_URL = `ws://${HOST}:${PORT}/bridge` +const MAX_CONNECT_ATTEMPTS = 10 +const INITIAL_BACKOFF_MS = 300 +const TOOL_CALL_TIMEOUT_MS = 60_000 + +// ── Logging (stderr only — stdout reserved for MCP) ───────────────────────── function log(msg: string) { - process.stderr.write(`[aipex-bridge] ${msg}\n`); + process.stderr.write(`[aipex-bridge] ${msg}\n`) } -// ── JSON-RPC types ────────────────────────────────────────────────────────── +// ── Daemon connection ─────────────────────────────────────────────────────── -interface JSONRPCRequest { - jsonrpc: "2.0"; - id: number | string | null; - method: string; - params?: unknown; +let daemonWs: WebSocket | undefined +let nextReqId = 1 + +interface PendingCall { + resolve: (result: unknown) => void + reject: (error: Error) => void + timer: ReturnType } -interface JSONRPCResponse { - jsonrpc: "2.0"; - id: number | string | null; - result?: unknown; - error?: { code: number; message: string }; +const pendingCalls = new Map() + +function isDaemonConnected(): boolean { + return !!daemonWs && daemonWs.readyState === WebSocket.OPEN } -type JSONRPCMessage = JSONRPCRequest | JSONRPCResponse; - -interface McpTool { - name: string; - description?: string; - inputSchema?: unknown; -} - -// ── AIPex WebSocket connection state ──────────────────────────────────────── - -let aipexSocket: WebSocket | null = null; -let aipexReady = false; -let cachedTools: McpTool[] = []; - -let nextAipexId = 1; -const aipexPending = new Map< - number | string, - { resolve: (v: unknown) => void; reject: (e: Error) => void } ->(); - -// ── Respond to MCP client (stdout, JSON-RPC 2.0) ─────────────────────────── - -function respond(id: number | string | null, result: unknown) { - const msg: JSONRPCResponse = { jsonrpc: "2.0", id, result }; - process.stdout.write(`${JSON.stringify(msg)}\n`); -} - -function respondError( - id: number | string | null, - code: number, - message: string, -) { - const msg: JSONRPCResponse = { jsonrpc: "2.0", id, error: { code, message } }; - process.stdout.write(`${JSON.stringify(msg)}\n`); -} - -// ── Send requests to AIPex (WebSocket) ────────────────────────────────────── - -function sendToAipex(method: string, params: unknown = {}): Promise { - if (!aipexSocket || aipexSocket.readyState !== WebSocket.OPEN) { - return Promise.reject(new Error("AIPex extension not connected")); +function sendToolCallToDaemon( + toolName: string, + args: Record +): Promise { + if (!isDaemonConnected()) { + return Promise.reject( + new Error( + "Not connected to AIPex daemon. The daemon may have stopped.\n" + + "Restart the bridge or check if port " + + PORT + + " is available." + ) + ) } - const id = nextAipexId++; - const msg = { jsonrpc: "2.0", id, method, params }; - aipexSocket.send(JSON.stringify(msg)); + + const id = nextReqId++ + const msg = { + jsonrpc: "2.0", + id, + method: "tools/call", + params: { name: toolName, arguments: args } + } + daemonWs!.send(JSON.stringify(msg)) + return new Promise((resolve, reject) => { - aipexPending.set(id, { resolve, reject }); - }); -} - -// ── Handle messages from AIPex ────────────────────────────────────────────── - -function handleAipexMessage(raw: string) { - let msg: JSONRPCMessage; - try { - msg = JSON.parse(raw); - } catch { - log(`Failed to parse AIPex message: ${raw.slice(0, 100)}`); - return; - } - - if ("result" in msg || "error" in msg) { - const res = msg as JSONRPCResponse; - const p = aipexPending.get(res.id!); - if (p) { - aipexPending.delete(res.id!); - if (res.error) { - p.reject(new Error(res.error.message)); - } else { - p.resolve(res.result); + const timer = setTimeout(() => { + if (pendingCalls.has(id)) { + pendingCalls.delete(id) + reject( + new Error( + `Tool '${toolName}' timed out after ${TOOL_CALL_TIMEOUT_MS}ms` + ) + ) } - } + }, TOOL_CALL_TIMEOUT_MS) + pendingCalls.set(id, { resolve, reject, timer }) + }) +} + +function handleDaemonMessage(raw: string) { + let msg: Record + try { + msg = JSON.parse(raw) + } catch { + return + } + + const id = msg.id as number | undefined + if (id == null) return + + const pending = pendingCalls.get(id) + if (!pending) return + + clearTimeout(pending.timer) + pendingCalls.delete(id) + + if (msg.error) { + const err = msg.error as { message?: string } + pending.reject(new Error(err.message || "Daemon returned an error")) + } else { + pending.resolve(msg.result) } } -// ── MCP handshake (runs automatically when AIPex connects) ────────────────── - -async function doAipexHandshake(socket: WebSocket) { - log("Starting MCP handshake with AIPex..."); - - const initResult = (await sendToAipex("initialize", { - protocolVersion: "2024-11-05", - capabilities: {}, - clientInfo: { name: "aipex-mcp-bridge", version: "1.0.0" }, - })) as Record; - - const serverInfo = initResult?.serverInfo as - | Record - | undefined; - log( - `AIPex server: ${serverInfo?.name ?? "?"} v${serverInfo?.version ?? "?"}`, - ); - - socket.send( - JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }), - ); - - const toolsResult = (await sendToAipex("tools/list")) as Record< - string, - unknown - >; - cachedTools = (toolsResult?.tools as McpTool[]) ?? []; - aipexReady = true; - - log(`Handshake complete. ${cachedTools.length} tools available.`); +function rejectAllPending(reason: string) { + for (const [, entry] of pendingCalls) { + clearTimeout(entry.timer) + entry.reject(new Error(reason)) + } + pendingCalls.clear() } -// ── Handle MCP requests from the agent (stdin) ────────────────────────────── +// ── Daemon lifecycle ──────────────────────────────────────────────────────── -async function handleAgentRequest(req: JSONRPCRequest) { - const { id, method, params } = req; +function tryConnectToDaemon(): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(DAEMON_URL) + const timeout = setTimeout(() => { + ws.terminate() + reject(new Error("Connection timeout")) + }, 3_000) - if (method === "initialize") { - respond(id, { - protocolVersion: "2024-11-05", - capabilities: { tools: {} }, - serverInfo: { name: "aipex-mcp-bridge", version: "1.0.0" }, - }); - return; + ws.on("open", () => { + clearTimeout(timeout) + resolve(ws) + }) + + ws.on("error", (err) => { + clearTimeout(timeout) + reject(err) + }) + }) +} + +function spawnDaemon() { + const __filename = fileURLToPath(import.meta.url) + const __dirname = dirname(__filename) + const daemonPath = join(__dirname, "daemon.js") + + log(`Spawning daemon: ${daemonPath} --port ${PORT} --host ${HOST}`) + + const child = fork(daemonPath, ["--port", String(PORT), "--host", HOST], { + detached: true, + stdio: "ignore" + }) + child.unref() + child.on("error", (err) => { + log(`Failed to spawn daemon: ${err.message}`) + }) +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)) +} + +async function connectWithAutoSpawn(): Promise { + // First, try connecting to an existing daemon + try { + const ws = await tryConnectToDaemon() + log("Connected to existing daemon") + return ws + } catch { + // No daemon running } - if (method === "notifications/initialized") { - return; - } + // Spawn a new daemon + log("No daemon running, spawning one...") + spawnDaemon() - if (method === "tools/list") { - if (aipexReady && cachedTools.length > 0) { - respond(id, { tools: cachedTools }); - } else { - respond(id, { - tools: [ - { - name: "check_aipex_connection", - description: [ - "AIPex extension is not connected. To enable browser control:", - `1. Open Chrome → AIPex extension → Options page`, - `2. Set WebSocket URL to: ws://localhost:${WS_PORT}`, - `3. Click Connect`, - `Then reload this MCP server.`, - ].join("\n"), - inputSchema: { type: "object", properties: {} }, - }, - ], - }); - } - return; - } - - if (method === "tools/call") { - if ( - !aipexReady || - !aipexSocket || - aipexSocket.readyState !== WebSocket.OPEN - ) { - respondError( - id, - -32000, - `AIPex extension not connected. Open AIPex Options and connect to ws://localhost:${WS_PORT}`, - ); - return; - } + // Retry with backoff + let backoff = INITIAL_BACKOFF_MS + for (let attempt = 1; attempt <= MAX_CONNECT_ATTEMPTS; attempt++) { + await sleep(backoff) try { - const result = await sendToAipex( - "tools/call", - params as Record, - ); - respond(id, result); - } catch (e) { - respondError(id, -32000, e instanceof Error ? e.message : String(e)); + const ws = await tryConnectToDaemon() + log(`Connected to daemon (attempt ${attempt})`) + return ws + } catch { + backoff = Math.min(backoff * 1.5, 2_000) } - return; } - if (method === "ping") { - if (aipexReady && aipexSocket?.readyState === WebSocket.OPEN) { - try { - const result = await sendToAipex("ping"); - respond(id, result); - } catch { - respond(id, {}); - } - } else { - respond(id, {}); - } - return; - } - - respondError(id, -32601, `Method not found: ${method}`); + throw new Error( + `Failed to connect to daemon after ${MAX_CONNECT_ATTEMPTS} attempts.\n` + + `Check if port ${PORT} is available: lsof -i :${PORT}` + ) } -// ── Read MCP requests from stdin ──────────────────────────────────────────── +function setupDaemonConnection(ws: WebSocket) { + daemonWs = ws -const stdinRl = createInterface({ input: process.stdin }); + ws.on("message", (data) => handleDaemonMessage(data.toString())) -stdinRl.on("line", (line) => { - const trimmed = line.trim(); - if (!trimmed) return; + ws.on("close", () => { + log("Daemon connection lost, will reconnect on next tool call") + rejectAllPending("Daemon connection lost") + daemonWs = undefined + }) + + ws.on("error", (err) => { + log(`Daemon WebSocket error: ${err.message}`) + }) +} + +async function ensureDaemonConnection() { + if (isDaemonConnected()) return + + log("Reconnecting to daemon...") + const ws = await connectWithAutoSpawn() + setupDaemonConnection(ws) +} + +// ── MCP Server (stdio to IDE) ─────────────────────────────────────────────── + +const server = new Server( + { name: "aipex-mcp-bridge", version: "3.1.0" }, + { capabilities: { tools: {} } } +) + +server.setRequestHandler(ListToolsRequestSchema, async () => { + return { tools: toolSchemas } +}) + +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params + + const tool = toolSchemas.find((t) => t.name === name) + if (!tool) { + return { + content: [{ type: "text", text: `Tool "${name}" not found` }], + isError: true + } + } - let req: JSONRPCRequest; try { - req = JSON.parse(trimmed); - } catch { - log(`Failed to parse stdin: ${trimmed.slice(0, 100)}`); - return; - } + await ensureDaemonConnection() - handleAgentRequest(req).catch((e) => { - log( - `Error handling request: ${e instanceof Error ? e.message : String(e)}`, - ); - if (req.id != null) { - respondError(req.id, -32603, "Internal error"); + const result = (await sendToolCallToDaemon( + name, + (args ?? {}) as Record + )) as Record | undefined + + if (result && result.content) { + return result as { + content: Array<{ + type: string + text?: string + data?: string + mimeType?: string + }> + } } - }); -}); -stdinRl.on("close", () => { - log("stdin closed, shutting down"); - process.exit(0); -}); - -// ── WebSocket server (waits for AIPex extension to connect) ───────────────── - -const httpServer = createServer(); -const wss = new WebSocketServer({ server: httpServer }); - -wss.on("connection", (socket, req) => { - const addr = req.socket.remoteAddress ?? "unknown"; - - if (aipexSocket && aipexSocket.readyState === WebSocket.OPEN) { - log(`New connection from ${addr}, closing previous`); - aipexSocket.close(); - } - - aipexSocket = socket; - aipexReady = false; - cachedTools = []; - log(`AIPex extension connected from ${addr}`); - - socket.on("message", (data) => { - handleAipexMessage(data.toString()); - }); - - socket.on("close", () => { - log("AIPex extension disconnected"); - if (aipexSocket === socket) { - aipexSocket = null; - aipexReady = false; - cachedTools = []; + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }] } - }); - - socket.on("error", (err) => { - log(`Socket error: ${err.message}`); - }); - - doAipexHandshake(socket).catch((err: Error) => { - log(`Handshake failed: ${err.message}`); - }); -}); - -wss.on("error", (err) => { - log(`WebSocket server error: ${err.message}`); -}); + } catch (error) { + return { + content: [ + { + type: "text", + text: String(error instanceof Error ? error.message : error) + } + ], + isError: true + } + } +}) // ── Start ─────────────────────────────────────────────────────────────────── -httpServer.listen(WS_PORT, () => { - log(`AIPex MCP Bridge started`); - log(`WebSocket server listening on ws://localhost:${WS_PORT}`); - log(`Waiting for AIPex extension to connect...`); - log(`Open AIPex Options → set URL to ws://localhost:${WS_PORT} → Connect`); -}); +async function main() { + const ws = await connectWithAutoSpawn() + setupDaemonConnection(ws) + + const transport = new StdioServerTransport() + await server.connect(transport) + + log("AIPex MCP Bridge started (stdio → daemon relay)") + log(`Connected to daemon at ${DAEMON_URL}`) +} + +// ── Exit handling ─────────────────────────────────────────────────────────── + +process.stdin.on("close", async () => { + setTimeout(() => process.exit(0), 5_000) + rejectAllPending("Bridge shutting down") + if (daemonWs) daemonWs.close() + await server.close() + process.exit(0) +}) process.on("SIGINT", () => { - log("Shutting down..."); - wss.close(); - httpServer.close(); - process.exit(0); -}); + log("Shutting down...") + rejectAllPending("Bridge shutting down") + if (daemonWs) daemonWs.close() + process.exit(0) +}) + +main().catch((err) => { + log(`Fatal error: ${err instanceof Error ? err.message : String(err)}`) + process.exit(1) +}) diff --git a/mcp-bridge/src/cli.ts b/mcp-bridge/src/cli.ts new file mode 100644 index 0000000..e573ccc --- /dev/null +++ b/mcp-bridge/src/cli.ts @@ -0,0 +1,398 @@ +/** + * AIPex CLI — command-line tool for controlling the browser via AIPex. + * + * Connects to the AIPex daemon via WebSocket at /cli endpoint. + * Auto-spawns the daemon if it's not running (same as bridge.ts). + * + * Usage: + * aipex-cli [--param value ...] + * aipex-cli --list + * aipex-cli --help + * aipex-cli --json '{"name":"create_new_tab","arguments":{"url":"..."}}' + */ + +import { fork, spawn } from "node:child_process" +import { existsSync } from "node:fs" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { WebSocket } from "ws" + +import { toolSchemas, type ToolSchema } from "./tool-schemas.js" + +const DEFAULT_WS_URL = "ws://localhost:9223/cli" +const ENTRYPOINT_PATH = "/entrypoint.sh" +const CALL_TIMEOUT_MS = 60_000 +const MAX_RETRY_TIMEOUT_MS = parseInt( + process.env.AIPEX_CONNECT_TIMEOUT ?? "60000", + 10 +) +const INITIAL_BACKOFF_MS = 500 +const MAX_BACKOFF_MS = 5_000 + +// ── CLI argument parsing ───────────────────────────────────────────────────── + +const args = process.argv.slice(2) + +if (args.length === 0 || args[0] === "-h") { + printUsage() + process.exit(0) +} + +if (args[0] === "--list") { + printToolList() + process.exit(0) +} + +if (args[0] === "--help") { + const toolName = args[1] + if (!toolName) { + process.stderr.write("Usage: aipex-cli --help \n") + process.exit(1) + } + printToolHelp(toolName) + process.exit(0) +} + +if (args[0] === "--json") { + const jsonStr = args[1] + if (!jsonStr) { + process.stderr.write( + 'Usage: aipex-cli --json \'{"name":"...","arguments":{...}}\'\n' + ) + process.exit(1) + } + try { + const parsed = JSON.parse(jsonStr) + runTool(parsed.name, parsed.arguments ?? {}) + } catch (e) { + process.stderr.write( + `Invalid JSON: ${e instanceof Error ? e.message : String(e)}\n` + ) + process.exit(1) + } +} else { + const toolName = args[0] + const toolArgs = parseToolArgs(args.slice(1), toolName) + runTool(toolName, toolArgs) +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function printUsage(): void { + process.stderr.write(` +AIPex CLI — control the browser from the command line + +Usage: + aipex-cli [--param value ...] + aipex-cli --list List all available tools + aipex-cli --help Show tool parameters + aipex-cli --json '{"name":"...","arguments":{...}}' + +Examples: + aipex-cli get_all_tabs + aipex-cli create_new_tab --url https://google.com + aipex-cli click --tabId 123 --uid btn-42 + aipex-cli search_elements --tabId 123 --query "button*" + aipex-cli capture_screenshot + +Environment: + AIPEX_WS_URL Daemon WebSocket URL (default: ws://localhost:9223/cli) + AIPEX_CONNECT_TIMEOUT Max ms to wait for daemon (default: 60000) +`) +} + +function printToolList(): void { + const maxLen = Math.max(...toolSchemas.map((t) => t.name.length)) + for (const tool of toolSchemas) { + const desc = tool.description.split("\n")[0].slice(0, 80) + process.stdout.write(` ${tool.name.padEnd(maxLen + 2)}${desc}\n`) + } +} + +function printToolHelp(name: string): void { + const tool = toolSchemas.find((t) => t.name === name) + if (!tool) { + process.stderr.write(`Unknown tool: ${name}\n`) + process.stderr.write(`Run 'aipex-cli --list' to see available tools.\n`) + process.exit(1) + } + process.stdout.write(`${tool.name}\n`) + process.stdout.write(` ${tool.description.split("\n")[0]}\n\n`) + const props = tool.inputSchema.properties + const required = new Set(tool.inputSchema.required ?? []) + if (Object.keys(props).length === 0) { + process.stdout.write(" No parameters.\n") + return + } + process.stdout.write(" Parameters:\n") + for (const [key, schema] of Object.entries(props)) { + const s = schema as Record + const typeStr = (s.type as string) ?? "any" + const reqStr = required.has(key) ? " (required)" : "" + const desc = (s.description as string) ?? "" + process.stdout.write(` --${key} <${typeStr}>${reqStr}\n`) + if (desc) { + process.stdout.write(` ${desc.split("\n")[0]}\n`) + } + } +} + +function parseToolArgs( + rawArgs: string[], + toolName: string +): Record { + const tool = toolSchemas.find((t) => t.name === toolName) + const props = tool?.inputSchema.properties ?? {} + const result: Record = {} + + for (let i = 0; i < rawArgs.length; i++) { + const arg = rawArgs[i] + if (!arg.startsWith("--")) { + process.stderr.write(`Unexpected argument: ${arg}\n`) + process.exit(1) + } + const key = arg.slice(2) + const value = rawArgs[++i] + if (value === undefined) { + process.stderr.write(`Missing value for --${key}\n`) + process.exit(1) + } + result[key] = coerceValue(value, key, props) + } + + return result +} + +function coerceValue( + value: string, + key: string, + props: Record +): unknown { + const schema = props[key] as Record | undefined + const type = schema?.type as string | undefined + + switch (type) { + case "number": { + const num = Number(value) + if (isNaN(num)) { + process.stderr.write(`--${key} expects a number, got: ${value}\n`) + process.exit(1) + } + return num + } + case "boolean": + return value === "true" || value === "1" + case "array": + case "object": + try { + return JSON.parse(value) + } catch { + process.stderr.write(`--${key} expects JSON (${type}), got: ${value}\n`) + process.exit(1) + } + default: + return value + } +} + +// ── WebSocket tool call ───────────────────────────────────────────────────── + +function isRetryableError(msg: string): boolean { + const lower = msg.toLowerCase() + return ( + lower.includes("not connected") || + lower.includes("extension is not connected") || + lower.includes("no extension") || + lower.includes("econnrefused") || + lower.includes("fetch failed") + ) +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)) +} + +function attemptWsToolCall( + wsUrl: string, + name: string, + args: Record +): Promise<{ retry: boolean; code: number }> { + return new Promise((resolve) => { + let settled = false + const ws = new WebSocket(wsUrl) + + const connectTimer = setTimeout(() => { + if (!settled) { + settled = true + ws.terminate() + resolve({ retry: true, code: 1 }) + } + }, 5_000) + + ws.on("error", () => { + if (!settled) { + settled = true + clearTimeout(connectTimer) + resolve({ retry: true, code: 1 }) + } + }) + + ws.on("close", () => { + if (!settled) { + settled = true + clearTimeout(connectTimer) + resolve({ retry: true, code: 1 }) + } + }) + + ws.on("open", () => { + clearTimeout(connectTimer) + const msg = { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name, arguments: args } + } + ws.send(JSON.stringify(msg)) + + const callTimer = setTimeout(() => { + if (!settled) { + settled = true + process.stderr.write( + `Tool '${name}' timed out after ${CALL_TIMEOUT_MS}ms\n` + ) + ws.close() + resolve({ retry: false, code: 1 }) + } + }, CALL_TIMEOUT_MS) + + ws.on("message", (data) => { + if (settled) return + settled = true + clearTimeout(callTimer) + + try { + const response = JSON.parse(data.toString()) + + if (response.error) { + const errMsg = + response.error.message ?? JSON.stringify(response.error) + if (isRetryableError(errMsg)) { + ws.close() + resolve({ retry: true, code: 1 }) + return + } + process.stderr.write(`Error: ${errMsg}\n`) + ws.close() + resolve({ retry: false, code: 1 }) + return + } + + const result = response.result + if (result?.content && Array.isArray(result.content)) { + for (const item of result.content) { + if (item.type === "text" && item.text) { + try { + const parsed = JSON.parse(item.text) + process.stdout.write(JSON.stringify(parsed, null, 2) + "\n") + } catch { + process.stdout.write(item.text + "\n") + } + } else if (item.type === "image") { + process.stdout.write( + `[Image: ${item.mimeType ?? "image/png"}, ${(((item.data?.length ?? 0) * 0.75) / 1024).toFixed(1)}KB]\n` + ) + } + } + } else if (result !== undefined) { + process.stdout.write(JSON.stringify(result, null, 2) + "\n") + } + } catch (e) { + process.stderr.write( + `Failed to parse response: ${data.toString().slice(0, 200)}\n` + ) + } + + ws.close() + resolve({ retry: false, code: 0 }) + }) + }) + }) +} + +function spawnDaemon(): void { + const __filename = fileURLToPath(import.meta.url) + const __dirname = dirname(__filename) + const daemonPath = join(__dirname, "daemon.js") + + try { + const child = fork(daemonPath, ["--port", "9223", "--host", "127.0.0.1"], { + detached: true, + stdio: "ignore" + }) + child.unref() + child.on("error", () => {}) + } catch { + // ignore spawn errors + } +} + +async function runTool( + name: string, + args: Record +): Promise { + const wsUrl = process.env.AIPEX_WS_URL ?? DEFAULT_WS_URL + const deadline = Date.now() + MAX_RETRY_TIMEOUT_MS + let backoff = INITIAL_BACKOFF_MS + let attempt = 0 + let daemonSpawned = false + + while (true) { + attempt++ + + const result = await attemptWsToolCall(wsUrl, name, args) + + if (!result.retry) { + process.exit(result.code) + } + + if (Date.now() >= deadline) { + process.stderr.write( + `Gave up after ${MAX_RETRY_TIMEOUT_MS / 1000}s — daemon not ready at ${wsUrl}\n` + ) + process.exit(1) + } + + if (attempt === 1) { + // In Docker, try the entrypoint script + if (existsSync(ENTRYPOINT_PATH)) { + process.stderr.write( + `[aipex-cli] Auto-starting services via ${ENTRYPOINT_PATH} ...\n` + ) + const child = spawn(ENTRYPOINT_PATH, [], { + detached: true, + stdio: "ignore", + shell: true, + env: { ...process.env, DISPLAY: ":99" } + }) + child.on("error", () => {}) + child.unref() + } else if (!daemonSpawned) { + process.stderr.write("[aipex-cli] Spawning daemon...\n") + spawnDaemon() + daemonSpawned = true + } + process.stderr.write( + `[aipex-cli] Waiting for AIPex daemon + extension ...\n` + ) + } + + const remaining = Math.max(0, deadline - Date.now()) + const wait = Math.min(backoff, remaining, MAX_BACKOFF_MS) + process.stderr.write( + `[aipex-cli] Retry #${attempt} in ${(wait / 1000).toFixed(1)}s\n` + ) + await sleep(wait) + backoff = Math.min(backoff * 2, MAX_BACKOFF_MS) + } +} diff --git a/mcp-bridge/src/daemon.ts b/mcp-bridge/src/daemon.ts new file mode 100644 index 0000000..c626a19 --- /dev/null +++ b/mcp-bridge/src/daemon.ts @@ -0,0 +1,441 @@ +/** + * AIPex MCP Daemon + * + * A background WebSocket relay that bridges multiple MCP bridge instances + * to a single AIPex Chrome extension connection. + * + * Architecture: + * + * bridge.ts #1 ──WS /bridge──┐ + * bridge.ts #2 ──WS /bridge──┤── this daemon ──WS /extension──▶ AIPex extension + * aipex-cli ──WS /cli─────┘ + * + * Spawned automatically by bridge.ts when no daemon is running. + * Self-terminates after IDLE_TIMEOUT_MS with no connections. + */ + +import { unlinkSync, writeFileSync } from "node:fs" +import { createServer } from "node:http" +import { homedir } from "node:os" +import { join } from "node:path" +import { WebSocket, WebSocketServer } from "ws" + +import { toolSchemas } from "./tool-schemas.js" + +// ── CLI args ──────────────────────────────────────────────────────────────── + +const cliArgs = process.argv.slice(2) + +function getArg(name: string, fallback: string): string { + const idx = cliArgs.indexOf(name) + return idx !== -1 && cliArgs[idx + 1] ? cliArgs[idx + 1] : fallback +} + +const PORT = parseInt(getArg("--port", "9223"), 10) +const HOST = getArg("--host", "127.0.0.1") +const PID_FILE = join(homedir(), ".aipex-daemon.pid") +const IDLE_TIMEOUT_MS = 30_000 +const TOOL_CALL_TIMEOUT_MS = 60_000 +const PING_INTERVAL_MS = 15_000 +const PING_TIMEOUT_MS = 5_000 + +// ── Logging ───────────────────────────────────────────────────────────────── + +function log(msg: string) { + process.stderr.write(`[aipex-daemon] ${msg}\n`) +} + +// ── Extension connection ──────────────────────────────────────────────────── + +let extensionWs: WebSocket | undefined +let nextExtId = 1 + +interface PendingExtCall { + bridgeSocket: WebSocket + bridgeReqId: number | string + timer: ReturnType +} + +const pendingExtCalls = new Map() +let extPingInterval: ReturnType | null = null + +function isExtensionConnected(): boolean { + return !!extensionWs && extensionWs.readyState === WebSocket.OPEN +} + +function setExtensionSocket(ws: WebSocket) { + if (extensionWs && extensionWs.readyState === WebSocket.OPEN) { + extensionWs.close() + } + rejectAllPendingExt("New extension connection replaced previous one") + extensionWs = ws + + ws.on("message", (data) => handleExtensionMessage(data.toString())) + + ws.on("close", () => { + if (extensionWs === ws) { + log("Extension disconnected") + stopExtPing() + rejectAllPendingExt("Extension disconnected") + extensionWs = undefined + resetIdleTimer() + } + }) + + ws.on("error", (err) => { + log(`Extension WebSocket error: ${err.message}`) + }) + + startExtPing() + resetIdleTimer() + log("Extension connected") +} + +function handleExtensionMessage(raw: string) { + let msg: Record + try { + msg = JSON.parse(raw) + } catch { + log(`Failed to parse extension message: ${raw.slice(0, 200)}`) + return + } + + const id = msg.id as number | undefined + if (id == null) return + + const pending = pendingExtCalls.get(id) + if (!pending) return + + clearTimeout(pending.timer) + pendingExtCalls.delete(id) + + const response: Record = { + jsonrpc: "2.0", + id: pending.bridgeReqId + } + + if (msg.error) { + response.error = msg.error + } else { + response.result = msg.result + } + + if (pending.bridgeSocket.readyState === WebSocket.OPEN) { + pending.bridgeSocket.send(JSON.stringify(response)) + } +} + +function forwardToolCall( + bridgeSocket: WebSocket, + bridgeReqId: number | string, + toolName: string, + args: Record +) { + if (!isExtensionConnected()) { + const errResp = { + jsonrpc: "2.0", + id: bridgeReqId, + error: { + code: -1, + message: + "AIPex extension is not connected. To connect:\n" + + "1. Open Chrome → AIPex extension → Options page\n" + + `2. Set WebSocket URL to ws://localhost:${PORT}/extension\n` + + "3. Click Connect" + } + } + if (bridgeSocket.readyState === WebSocket.OPEN) { + bridgeSocket.send(JSON.stringify(errResp)) + } + return + } + + const extId = nextExtId++ + const msg = { + jsonrpc: "2.0", + id: extId, + method: "tools/call", + params: { name: toolName, arguments: args } + } + extensionWs!.send(JSON.stringify(msg)) + + const timer = setTimeout(() => { + if (pendingExtCalls.has(extId)) { + pendingExtCalls.delete(extId) + const timeoutResp = { + jsonrpc: "2.0", + id: bridgeReqId, + error: { + code: -1, + message: `Tool '${toolName}' timed out after ${TOOL_CALL_TIMEOUT_MS}ms` + } + } + if (bridgeSocket.readyState === WebSocket.OPEN) { + bridgeSocket.send(JSON.stringify(timeoutResp)) + } + } + }, TOOL_CALL_TIMEOUT_MS) + + pendingExtCalls.set(extId, { bridgeSocket, bridgeReqId, timer }) +} + +function rejectAllPendingExt(reason: string) { + for (const [, entry] of pendingExtCalls) { + clearTimeout(entry.timer) + const errResp = { + jsonrpc: "2.0", + id: entry.bridgeReqId, + error: { code: -1, message: reason } + } + if (entry.bridgeSocket.readyState === WebSocket.OPEN) { + entry.bridgeSocket.send(JSON.stringify(errResp)) + } + } + pendingExtCalls.clear() +} + +function startExtPing() { + stopExtPing() + extPingInterval = setInterval(() => { + if (!isExtensionConnected()) { + stopExtPing() + return + } + const id = nextExtId++ + const msg = { jsonrpc: "2.0", id, method: "ping" } + extensionWs!.send(JSON.stringify(msg)) + + const timer = setTimeout(() => { + if (pendingExtCalls.has(id)) { + pendingExtCalls.delete(id) + log("Extension ping timeout, closing connection") + if (extensionWs) extensionWs.close() + } + }, PING_TIMEOUT_MS) + + pendingExtCalls.set(id, { + bridgeSocket: extensionWs!, + bridgeReqId: `ping-${id}`, + timer + }) + }, PING_INTERVAL_MS) +} + +function stopExtPing() { + if (extPingInterval) { + clearInterval(extPingInterval) + extPingInterval = null + } +} + +// ── Bridge client handling ────────────────────────────────────────────────── + +const bridgeClients = new Set() + +function handleBridgeMessage(socket: WebSocket, raw: string) { + let msg: Record + try { + msg = JSON.parse(raw) + } catch { + return + } + + const id = msg.id as number | string | undefined + const method = msg.method as string | undefined + + if (method === "tools/call") { + const params = (msg.params ?? {}) as Record + const name = params.name as string + const args = (params.arguments ?? {}) as Record + forwardToolCall(socket, id ?? 0, name, args) + return + } + + if (method === "tools/list") { + socket.send( + JSON.stringify({ jsonrpc: "2.0", id, result: { tools: toolSchemas } }) + ) + return + } + + if (method === "ping") { + socket.send(JSON.stringify({ jsonrpc: "2.0", id, result: {} })) + return + } + + if (method === "status") { + socket.send( + JSON.stringify({ + jsonrpc: "2.0", + id, + result: { + extensionConnected: isExtensionConnected(), + bridgeClients: bridgeClients.size + } + }) + ) + return + } +} + +// ── CLI client handling (backward compat) ─────────────────────────────────── + +function handleCliMessage(socket: WebSocket, raw: string) { + handleBridgeMessage(socket, raw) +} + +// ── Idle auto-shutdown ────────────────────────────────────────────────────── + +let idleTimer: ReturnType | null = null + +function resetIdleTimer() { + if (idleTimer) { + clearTimeout(idleTimer) + idleTimer = null + } + + if (bridgeClients.size === 0 && !isExtensionConnected()) { + idleTimer = setTimeout(() => { + log(`No connections for ${IDLE_TIMEOUT_MS / 1000}s, shutting down`) + shutdown() + }, IDLE_TIMEOUT_MS) + } +} + +// ── HTTP + WebSocket Server ───────────────────────────────────────────────── + +const httpServer = createServer((req, res) => { + if (req.url === "/health" && req.method === "GET") { + res.writeHead(200, { "Content-Type": "application/json" }) + res.end( + JSON.stringify({ + status: "ok", + extensionConnected: isExtensionConnected(), + bridgeClients: bridgeClients.size, + version: "3.1.0" + }) + ) + return + } + res.writeHead(404).end("Not found") +}) + +const extensionWss = new WebSocketServer({ noServer: true }) +const bridgeWss = new WebSocketServer({ noServer: true }) +const cliWss = new WebSocketServer({ noServer: true }) + +httpServer.on("upgrade", (req, socket, head) => { + const pathname = new URL(req.url ?? "/", "http://localhost").pathname + + if (pathname === "/extension" || pathname === "/") { + extensionWss.handleUpgrade(req, socket, head, (ws) => { + extensionWss.emit("connection", ws, req) + }) + } else if (pathname === "/bridge") { + bridgeWss.handleUpgrade(req, socket, head, (ws) => { + bridgeWss.emit("connection", ws, req) + }) + } else if (pathname === "/cli") { + cliWss.handleUpgrade(req, socket, head, (ws) => { + cliWss.emit("connection", ws, req) + }) + } else { + socket.destroy() + } +}) + +extensionWss.on("connection", (socket, req) => { + const addr = req.socket.remoteAddress ?? "unknown" + log(`Extension connected from ${addr}`) + setExtensionSocket(socket) +}) + +bridgeWss.on("connection", (socket) => { + bridgeClients.add(socket) + resetIdleTimer() + log(`Bridge client connected (total: ${bridgeClients.size})`) + + socket.on("message", (data) => handleBridgeMessage(socket, data.toString())) + + socket.on("close", () => { + bridgeClients.delete(socket) + resetIdleTimer() + log(`Bridge client disconnected (total: ${bridgeClients.size})`) + }) + + socket.on("error", (err) => { + log(`Bridge client error: ${err.message}`) + }) +}) + +cliWss.on("connection", (socket) => { + bridgeClients.add(socket) + resetIdleTimer() + + socket.on("message", (data) => handleCliMessage(socket, data.toString())) + + socket.on("close", () => { + bridgeClients.delete(socket) + resetIdleTimer() + }) +}) + +// ── PID file ──────────────────────────────────────────────────────────────── + +function writePidFile() { + try { + writeFileSync(PID_FILE, String(process.pid)) + } catch { + // non-critical + } +} + +function removePidFile() { + try { + unlinkSync(PID_FILE) + } catch { + // ignore + } +} + +// ── Start ─────────────────────────────────────────────────────────────────── + +httpServer.listen(PORT, HOST, () => { + writePidFile() + log(`AIPex MCP Daemon started (v3.1.0) pid=${process.pid}`) + log(`Extension WS: ws://${HOST}:${PORT}/extension`) + log(`Bridge WS: ws://${HOST}:${PORT}/bridge`) + log(`CLI WS: ws://${HOST}:${PORT}/cli`) + log(`Health: http://${HOST}:${PORT}/health`) + resetIdleTimer() +}) + +httpServer.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE") { + log(`Port ${PORT} already in use — another daemon is likely running`) + process.exit(0) + } + log(`Server error: ${err.message}`) + process.exit(1) +}) + +// ── Shutdown ──────────────────────────────────────────────────────────────── + +function shutdown() { + stopExtPing() + rejectAllPendingExt("Daemon shutting down") + if (extensionWs) { + extensionWs.close() + extensionWs = undefined + } + extensionWss.close() + bridgeWss.close() + cliWss.close() + httpServer.close() + removePidFile() + if (idleTimer) clearTimeout(idleTimer) + process.exit(0) +} + +process.on("SIGINT", shutdown) +process.on("SIGTERM", shutdown) diff --git a/mcp-bridge/src/tool-schemas.ts b/mcp-bridge/src/tool-schemas.ts new file mode 100644 index 0000000..f0a4bf2 --- /dev/null +++ b/mcp-bridge/src/tool-schemas.ts @@ -0,0 +1,738 @@ +/** + * Static MCP tool schemas for the bridge. + * + * These mirror the definitions in src/mcp/tools/unified-tool-definitions.ts + * but are expressed as plain JSON Schema so the bridge has zero dependency + * on the extension runtime (no Zod, no Chrome APIs). + * + * When tools are added/removed in the extension, update this file accordingly. + */ + +export interface ToolSchema { + name: string + description: string + inputSchema: { + type: "object" + properties: Record + required?: string[] + } +} + +export const toolSchemas: ToolSchema[] = [ + // ===== Browser Tools ===== + { + name: "get_all_tabs", + description: + "Get all open tabs across all windows with their IDs, titles, and URLs", + inputSchema: { type: "object", properties: {}, required: [] } + }, + { + name: "get_current_tab", + description: "Get information about the currently active tab", + inputSchema: { type: "object", properties: {}, required: [] } + }, + { + name: "switch_to_tab", + description: "Switch to a specific tab by ID", + inputSchema: { + type: "object", + properties: { + tabId: { type: "number", description: "The ID of the tab to switch to" } + }, + required: ["tabId"] + } + }, + { + name: "create_new_tab", + description: "Create a new tab with the specified URL", + inputSchema: { + type: "object", + properties: { + url: { type: "string", description: "The URL to open in the new tab" } + }, + required: ["url"] + } + }, + { + name: "get_tab_info", + description: "Get detailed information about a specific tab", + inputSchema: { + type: "object", + properties: { + tabId: { type: "number", description: "The ID of the tab" } + }, + required: ["tabId"] + } + }, + { + name: "close_tab", + description: "Close a specific tab", + inputSchema: { + type: "object", + properties: { + tabId: { type: "number", description: "The ID of the tab to close" } + }, + required: ["tabId"] + } + }, + { + name: "organize_tabs", + description: "Use AI to automatically group tabs by topic/purpose", + inputSchema: { type: "object", properties: {}, required: [] } + }, + { + name: "ungroup_tabs", + description: "Remove all tab groups in the current window", + inputSchema: { type: "object", properties: {}, required: [] } + }, + + // ===== UI Tools ===== + { + name: "search_elements", + description: `[FAST - USE FIRST] Search for elements in the current page using glob/grep patterns against the DOM snapshot. Returns matching elements with their UIDs for direct UID-based interaction. + +GLOB SYNTAX: +- * matches any characters (e.g. button* finds all buttons) +- ? matches exactly one character +- [abc] matches any of those characters +- {a,b,c} matches any of those alternatives (e.g. {button,input}* finds all buttons and inputs) +- Patterns are case-sensitive by default; use [Ll] to match both cases + +STARTER QUERIES: +- Broad scan: {button,link,input,StaticText}* +- All interactive: {button,input,textarea,select,a}* +- Login/auth: *[Ll]ogin*, *[Ss]ign* +- Submit/save: *[Ss]ubmit*, *[Ss]ave*, *[Cc]onfirm* +- Search boxes: *[Ss]earch*, {input,textarea}* +- Navigation: {nav,link,a}* + +WORKFLOW: +1. Call search_elements with a broad pattern to discover elements +2. Elements in the result have uid= attributes (e.g. uid=btn-42) +3. Pass that UID to click(tabId, uid) or fill_element_by_uid(tabId, uid, value) +4. If 0 results after 2 different patterns, fall back to capture_screenshot(sendToLLM=true) + +This is the PREFERRED first step — much faster and cheaper than screenshots.`, + inputSchema: { + type: "object", + properties: { + tabId: { + type: "number", + description: "The ID of the tab to search the elements in" + }, + query: { + type: "string", + description: "Search query string with grep/glob pattern support" + }, + contextLevels: { + type: "number", + description: "Number of context lines to include" + } + }, + required: ["tabId", "query"] + } + }, + { + name: "click", + description: "Click an element using its unique UID from a snapshot", + inputSchema: { + type: "object", + properties: { + tabId: { type: "number", description: "The ID of the tab to click on" }, + uid: { + type: "string", + description: + "The unique identifier of an element from the page snapshot" + }, + dblClick: { + type: "boolean", + description: "Set to true for double clicks" + } + }, + required: ["tabId", "uid"] + } + }, + { + name: "fill_element_by_uid", + description: "Fill an input element using its unique UID from a snapshot", + inputSchema: { + type: "object", + properties: { + tabId: { + type: "number", + description: "The ID of the tab to fill the element in" + }, + uid: { + type: "string", + description: "The unique identifier of the element to fill" + }, + value: { + type: "string", + description: "The value to fill into the element" + } + }, + required: ["tabId", "uid", "value"] + } + }, + { + name: "get_editor_value", + description: + "Get the complete content from a code editor (Monaco, CodeMirror, ACE) or textarea without truncation. Use this before filling to avoid data loss.", + inputSchema: { + type: "object", + properties: { + tabId: { type: "number", description: "The ID of the tab" }, + uid: { + type: "string", + description: + "The unique identifier of the editor element from snapshot" + } + }, + required: ["tabId", "uid"] + } + }, + { + name: "fill_form", + description: + "Fill multiple form elements at once using their UIDs from a snapshot", + inputSchema: { + type: "object", + properties: { + tabId: { + type: "number", + description: "The ID of the tab to fill the elements in" + }, + elements: { + type: "array", + description: "Array of elements to fill with their UIDs and values" + } + }, + required: ["tabId", "elements"] + } + }, + { + name: "hover_element_by_uid", + description: "Hover over an element using its unique UID from a snapshot", + inputSchema: { + type: "object", + properties: { + tabId: { + type: "number", + description: "The ID of the tab to hover over" + }, + uid: { + type: "string", + description: "The unique identifier of the element to hover over" + } + }, + required: ["tabId", "uid"] + } + }, + { + name: "upload_file_to_input", + description: `Upload a pre-attached file to a file input element () on the page. + +PREREQUISITES: +- The user must have already attached a file using the attachment button in the AIPex sidebar BEFORE sending the message +- The file content is NEVER sent to the AI (privacy guaranteed) + +WORKFLOW: +1. Call this tool with just the tabId — the tool automatically finds the file input (including hidden ones) +2. If the page has multiple file inputs, use input_index to select which one (0 = first) +3. Optionally provide uid from a snapshot if you know the exact element + +NOTE: Most websites hide the actual behind a styled button. This tool handles both visible and hidden file inputs automatically — no need to find the element UID first. + +AFTER UPLOAD: take a screenshot to verify the file was accepted, then proceed to submit the form.`, + inputSchema: { + type: "object", + properties: { + tabId: { + type: "number", + description: "The ID of the tab containing the file input element" + }, + uid: { + type: "string", + description: + "UID of the element from the page snapshot. OPTIONAL — if omitted or the element is hidden, the tool automatically finds the file input by CSS selector." + }, + input_index: { + type: "number", + description: + "0-based index to select which file input to target when the page has multiple. Defaults to 0. Only used when uid is not provided or not found." + }, + file_id: { + type: "string", + description: + "ID of the specific attached file to use (the 'ref' value from the [Attached file...] message). Omit to use the most recently attached file." + }, + file_path: { + type: "string", + description: + "Absolute local file path to upload directly (e.g. '/Users/me/resume.pdf'). Uses CDP DOM.setFileInputFiles — no file content is read into memory. Takes priority over file_id and pre-attached files when provided." + } + }, + required: ["tabId"] + } + }, + { + name: "computer", + description: `[HIGH-COST FALLBACK] Coordinate-based mouse/keyboard interaction using screenshot pixels. + +PREFER UID-BASED TOOLS FIRST: For clicking buttons, filling forms, or hovering elements, use search_elements to get UIDs, then use click/fill_element_by_uid/hover_element_by_uid. These are faster and more reliable. + +USE THIS TOOL ONLY WHEN: +- search_elements returned 0 matches after trying 2 different query patterns +- UID-based actions failed twice (element not interactable) +- The goal requires visual/pixel-level interaction: canvas apps, drag-and-drop, sliders, charts, hover-only menus + +PREREQUISITE: If you choose coordinate actions, you MUST first call capture_screenshot(sendToLLM=true). Coordinates are in screenshot pixel space. + +* Click element centers, not edges. Adjust if clicks miss.`, + inputSchema: { + type: "object", + properties: { + action: { + type: "string", + enum: [ + "left_click", + "right_click", + "type", + "scroll", + "key", + "left_click_drag", + "double_click", + "triple_click", + "scroll_to", + "hover" + ], + description: `The action to perform: +* \`left_click\`: Click the left mouse button at the specified coordinates. +* \`right_click\`: Click the right mouse button at the specified coordinates to open context menus. +* \`double_click\`: Double-click the left mouse button at the specified coordinates. +* \`triple_click\`: Triple-click the left mouse button at the specified coordinates. +* \`type\`: Type a string of text at the current cursor position. +* \`scroll\`: Scroll up, down, left, or right at the specified coordinates. +* \`key\`: Press a specific keyboard key or key combination. +* \`left_click_drag\`: Drag from start_coordinate to coordinate. +* \`scroll_to\`: Scroll an element into view using its element UID from snapshot. +* \`hover\`: Move the mouse cursor to the specified coordinates without clicking. Useful for revealing tooltips, dropdown menus, or triggering hover states.` + }, + coordinate: { + type: "array", + description: + "(x, y): The x (pixels from the left edge) and y (pixels from the top edge) coordinates in screenshot pixel space. Required for left_click, right_click, double_click, triple_click, scroll, and hover. For left_click_drag, this is the end position." + }, + text: { + type: "string", + description: + 'The text to type (for type action) or the key(s) to press (for key action). For key action: Provide space-separated keys (e.g., "Backspace Backspace Delete"). Supports keyboard shortcuts using the platform modifier key (use "cmd" on Mac, "ctrl" on Windows/Linux, e.g., "cmd+a" for select all). Common keys: Enter, Tab, Escape, ArrowUp/Down/Left/Right, Backspace, Delete.' + }, + start_coordinate: { + type: "array", + description: + "Starting coordinates for left_click_drag action in screenshot pixel space." + }, + scroll_direction: { + type: "string", + enum: ["up", "down", "left", "right"], + description: "Direction to scroll for scroll action." + }, + scroll_amount: { + type: "number", + description: + "Number of pixels to scroll. Defaults to ~2 viewport heights for standard scrolling." + }, + tabId: { + type: "number", + description: + "The ID of the tab to operate on. Defaults to current active tab." + }, + uid: { + type: "string", + description: "Element UID from snapshot for scroll_to action." + } + }, + required: ["action"] + } + }, + + // ===== Page Tools ===== + { + name: "get_page_metadata", + description: + "Get page metadata including title, description, keywords, etc.", + inputSchema: { type: "object", properties: {}, required: [] } + }, + { + name: "scroll_to_element", + description: "Scroll to a DOM element and center it in the viewport", + inputSchema: { + type: "object", + properties: { + selector: { + type: "string", + description: "CSS selector of the element to scroll to" + } + }, + required: ["selector"] + } + }, + { + name: "highlight_element", + description: "Permanently highlight DOM elements with drop shadow effect", + inputSchema: { + type: "object", + properties: { + selector: { + type: "string", + description: "CSS selector of the element to highlight" + }, + color: { + type: "string", + description: "Shadow color (e.g., '#00d4ff')" + }, + duration: { + type: "number", + description: "Duration in milliseconds (0 = permanent)" + }, + intensity: { + type: "string", + enum: ["subtle", "normal", "strong"] + }, + persist: { + type: "boolean", + description: "Whether to keep the highlight permanently" + } + }, + required: ["selector"] + } + }, + { + name: "highlight_text_inline", + description: + "Highlight specific words or phrases within text content using inline styling", + inputSchema: { + type: "object", + properties: { + selector: { + type: "string", + description: + "CSS selector of the element(s) containing the text to search" + }, + searchText: { + type: "string", + description: "The text or phrase to highlight" + }, + caseSensitive: { type: "boolean" }, + wholeWords: { type: "boolean" }, + highlightColor: { type: "string" }, + backgroundColor: { type: "string" }, + fontWeight: { type: "string" }, + persist: { type: "boolean" } + }, + required: ["selector", "searchText"] + } + }, + + // ===== Screenshot Tools ===== + { + name: "capture_screenshot", + description: `[HIGH-COST FALLBACK] Capture screenshot of current visible tab. + +TRY search_elements FIRST: For most interactions (clicking, filling, reading), use search_elements + UID-based tools. They are faster and don't send images to LLM. + +USE THIS ONLY WHEN: +- search_elements cannot find the target after 2 query attempts +- You need to see visual layout, images, charts, or canvas content +- The page uses non-standard rendering that snapshots miss + +When sendToLLM=true: Sends image to LLM (higher latency/cost, may capture sensitive on-screen data) and enables the computer tool for coordinate-based actions.`, + inputSchema: { + type: "object", + properties: { + sendToLLM: { + type: "boolean", + description: + "Whether to send the screenshot to LLM for visual analysis. When true, enables computer tool for coordinate actions. Use sparingly - adds latency and token cost." + } + }, + required: [] + } + }, + { + name: "capture_tab_screenshot", + description: `[HIGH-COST FALLBACK] Capture screenshot of a specific tab by ID. + +TRY search_elements FIRST: For most interactions, use search_elements + UID-based tools instead. + +USE THIS ONLY WHEN: Visual verification is essential, search_elements failed, or you need to see images/charts/canvas. + +When sendToLLM=true: Sends image to LLM (higher latency/cost) and enables coordinate-based actions.`, + inputSchema: { + type: "object", + properties: { + tabId: { + type: "number", + description: "The ID of the tab to capture" + }, + sendToLLM: { + type: "boolean", + description: + "Whether to send the screenshot to LLM for visual analysis. When true, enables computer tool. Use sparingly." + } + }, + required: ["tabId"] + } + }, + + // ===== Download Tools ===== + { + name: "download_text_as_markdown", + description: + "Download text content as a markdown file to the user's local filesystem", + inputSchema: { + type: "object", + properties: { + text: { + type: "string", + description: "The text content to download as markdown" + }, + filename: { + type: "string", + description: + "Descriptive filename for the download (without .md extension)" + }, + folderPath: { + type: "string", + description: "Optional folder path for organizing downloads" + }, + displayResults: { + type: "boolean", + description: "Whether to display the download results" + } + }, + required: ["text"] + } + }, + { + name: "download_image", + description: + "Download an image from base64 data to the user's local filesystem", + inputSchema: { + type: "object", + properties: { + imageData: { + type: "string", + description: "The base64 image data URL (data:image/...)" + }, + filename: { + type: "string", + description: + "Descriptive filename for the download (without extension)" + }, + folderPath: { + type: "string", + description: "Optional folder path for organizing downloads" + } + }, + required: ["imageData"] + } + }, + { + name: "download_chat_images", + description: + "Download multiple images from chat messages to the user's local filesystem", + inputSchema: { + type: "object", + properties: { + messages: { + type: "array", + description: "Array of chat messages containing images" + }, + folderPrefix: { + type: "string", + description: "Descriptive folder name for organizing downloads" + }, + filenamingStrategy: { + type: "string", + enum: ["descriptive", "sequential", "timestamp"] + }, + displayResults: { + type: "boolean", + description: "Whether to display the download results" + } + }, + required: ["messages"] + } + }, + + // ===== Intervention Tools ===== + { + name: "list_interventions", + description: + "List all available human intervention tools. Use this to discover what types of human input you can request.", + inputSchema: { + type: "object", + properties: { + enabledOnly: { + type: "boolean", + description: "If true, only return enabled interventions" + } + }, + required: [] + } + }, + { + name: "get_intervention_info", + description: + "Get detailed information about a specific intervention type, including its input/output schema and examples.", + inputSchema: { + type: "object", + properties: { + type: { + type: "string", + description: + 'The type of intervention to get information about (e.g., "monitor-operation", "voice-input", "user-selection")' + } + }, + required: ["type"] + } + }, + { + name: "request_intervention", + description: + "Request human intervention during task execution. This allows you to ask the user to click on an element, provide voice input, or make a selection. IMPORTANT: Only use this when absolutely necessary and when the current conversation mode allows interventions.", + inputSchema: { + type: "object", + properties: { + type: { + type: "string", + description: + 'The type of intervention to request (e.g., "monitor-operation", "voice-input", "user-selection")' + }, + params: { + description: "Type-specific parameters for the intervention" + }, + timeout: { + type: "number", + description: "Timeout in seconds (default: 300)" + }, + reason: { + type: "string", + description: + "A clear explanation to the user about why you need their input" + } + }, + required: ["type"] + } + }, + { + name: "cancel_intervention", + description: "Cancel the currently active intervention request.", + inputSchema: { + type: "object", + properties: { + id: { + type: "string", + description: + "Optional intervention ID to cancel. If not provided, cancels the current active intervention." + } + }, + required: [] + } + }, + + // ===== Skill Tools ===== + { + name: "load_skill", + description: + "Load the main content (SKILL.md) of a skill. Use this to understand what a skill does, its capabilities, available scripts, and how to use it.", + inputSchema: { + type: "object", + properties: { + name: { type: "string", description: "The name of the skill to load" } + }, + required: ["name"] + } + }, + { + name: "execute_skill_script", + description: + "Execute a script that belongs to a skill. Scripts are located in the scripts/ directory of the skill package and can perform various operations.", + inputSchema: { + type: "object", + properties: { + skillName: { type: "string", description: "The name of the skill" }, + scriptPath: { + type: "string", + description: + 'The path to the script file (e.g., "scripts/init_skill.js"), MUST start with "scripts/"' + }, + args: { description: "Arguments to pass to the script" } + }, + required: ["skillName", "scriptPath"] + } + }, + { + name: "read_skill_reference", + description: + "Read a reference document from a skill. Reference files are located in the references/ directory and contain additional documentation, guides, or examples.", + inputSchema: { + type: "object", + properties: { + skillName: { type: "string", description: "The name of the skill" }, + refPath: { + type: "string", + description: + 'The path to the reference file (e.g., "references/guide.md"), MUST start with "references/"' + } + }, + required: ["skillName", "refPath"] + } + }, + { + name: "get_skill_asset", + description: + "Get an asset file from a skill. Assets are located in the assets/ directory and can be images, data files, or other resources.", + inputSchema: { + type: "object", + properties: { + skillName: { type: "string", description: "The name of the skill" }, + assetPath: { + type: "string", + description: + 'The path to the asset file (e.g., "assets/icon.png"), MUST start with "assets/"' + } + }, + required: ["skillName", "assetPath"] + } + }, + { + name: "list_skills", + description: + "List all available skills in the system. Shows enabled skills by default, or all skills if specified.", + inputSchema: { + type: "object", + properties: { + enabledOnly: { + type: "boolean", + description: "If true, only show enabled skills. Default: false" + } + }, + required: [] + } + }, + { + name: "get_skill_info", + description: + "Get detailed information about a specific skill, including its scripts, references, assets, and metadata.", + inputSchema: { + type: "object", + properties: { + skillName: { type: "string", description: "The name of the skill" } + }, + required: ["skillName"] + } + } +] diff --git a/mcp-bridge/tsup.config.ts b/mcp-bridge/tsup.config.ts index 6f52f4e..bb20557 100644 --- a/mcp-bridge/tsup.config.ts +++ b/mcp-bridge/tsup.config.ts @@ -1,11 +1,11 @@ -import { defineConfig } from "tsup"; +import { defineConfig } from "tsup" export default defineConfig({ - entry: ["src/bridge.ts"], + entry: ["src/bridge.ts", "src/daemon.ts", "src/cli.ts"], format: ["esm"], target: "node18", clean: true, banner: { - js: "#!/usr/bin/env node", - }, -}); + js: "#!/usr/bin/env node" + } +})