From db5e499dd6b3f5468a1acbeeec73cd78ab8257f1 Mon Sep 17 00:00:00 2001 From: Andrew Leech Date: Wed, 22 Apr 2026 11:17:15 +1000 Subject: [PATCH 1/2] Add MCP server for Claude remote control of ViperIDE MCP (Model Context Protocol) server that lets Claude Code or Claude Desktop programmatically control ViperIDE while the user interacts with it in the browser simultaneously. Core: the MCP server serves built ViperIDE on localhost, opens the browser on first tool call, and bridges MCP tool calls over WebSocket to a control client injected into the IDE. 23 MCP tools expose device connection, file operations, editor control, code execution, terminal I/O, and package management. Serial bridge: Node.js opens USB serial ports directly and relays data over WebSocket, faking a WebREPL handshake so ViperIDE's existing transport works unchanged. Bypasses browser WebSerial permission picker entirely. Distribution: build-dist.sh produces per-platform .tar.gz and .mcpb (Claude Desktop Extension) bundles with pre-built ViperIDE assets. build-npm.sh stages a cross-platform npm package. CI workflow builds all three platforms and publishes to npm + GitHub Releases on tag. Also increases the WebREPL password prompt timeout from 1s to 5s for slow embedded WiFi stacks. --- .github/workflows/mcp-dist.yml | 147 ++++++++++ .gitignore | 1 + eslint.config.mjs | 2 +- mcp/.gitignore | 4 + mcp/.mcpbignore | 8 + mcp/README.md | 128 +++++++++ mcp/build-dist.sh | 142 ++++++++++ mcp/build-npm.sh | 89 ++++++ mcp/install.sh | 96 +++++++ mcp/manifest.json | 43 +++ mcp/package.json | 17 ++ mcp/src/bridge.js | 112 ++++++++ mcp/src/ide-server.js | 116 ++++++++ mcp/src/index.js | 305 ++++++++++++++++++++ mcp/src/serial-bridge.js | 144 ++++++++++ mcp/test/e2e.js | 499 +++++++++++++++++++++++++++++++++ src/app.js | 17 ++ src/control_client.js | 339 ++++++++++++++++++++++ src/rawmode.js | 2 +- src/transports.js | 2 +- 20 files changed, 2210 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/mcp-dist.yml create mode 100644 mcp/.gitignore create mode 100644 mcp/.mcpbignore create mode 100644 mcp/README.md create mode 100755 mcp/build-dist.sh create mode 100755 mcp/build-npm.sh create mode 100755 mcp/install.sh create mode 100644 mcp/manifest.json create mode 100644 mcp/package.json create mode 100644 mcp/src/bridge.js create mode 100644 mcp/src/ide-server.js create mode 100644 mcp/src/index.js create mode 100644 mcp/src/serial-bridge.js create mode 100644 mcp/test/e2e.js create mode 100644 src/control_client.js diff --git a/.github/workflows/mcp-dist.yml b/.github/workflows/mcp-dist.yml new file mode 100644 index 0000000..cf5f7fb --- /dev/null +++ b/.github/workflows/mcp-dist.yml @@ -0,0 +1,147 @@ +name: Build and publish MCP distributions + +on: + push: + tags: ['mcp-v*'] + workflow_dispatch: + +permissions: + contents: write + id-token: write + +env: + # Extract version from tag (mcp-v0.5.2-mcp.1 -> 0.5.2-mcp.1), or use package.json for manual runs + MCP_VERSION: ${{ startsWith(github.ref, 'refs/tags/mcp-v') && github.ref_name || '' }} + +jobs: + build-viperide: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - uses: actions/setup-python@v5 + with: + python-version: '3.x' + - run: pip install requests + - run: npm install && python3 build.py + - uses: actions/upload-artifact@v4 + with: + name: viperide-build + path: build/ + + build-dist: + needs: build-viperide + strategy: + matrix: + include: + - os: ubuntu-latest + platform: linux + - os: macos-latest + platform: darwin + - os: windows-latest + platform: win32 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - uses: actions/download-artifact@v4 + with: + name: viperide-build + path: build/ + - name: Set version from tag + if: env.MCP_VERSION != '' + working-directory: mcp + shell: bash + run: | + VERSION="${MCP_VERSION#mcp-v}" + node -e "const p=require('./package.json'); p.version='$VERSION'; require('fs').writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')" + - name: Build distribution + working-directory: mcp + shell: bash + run: bash build-dist.sh ${{ matrix.platform }} + - uses: actions/upload-artifact@v4 + with: + name: dist-${{ matrix.platform }} + path: | + mcp/dist/*.tar.gz + mcp/dist/*.mcpb + + build-npm: + needs: build-viperide + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - uses: actions/download-artifact@v4 + with: + name: viperide-build + path: build/ + - name: Set version from tag + if: env.MCP_VERSION != '' + working-directory: mcp + run: | + VERSION="${MCP_VERSION#mcp-v}" + node -e "const p=require('./package.json'); p.version='$VERSION'; require('fs').writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')" + - name: Build npm package + working-directory: mcp + run: bash build-npm.sh + - uses: actions/upload-artifact@v4 + with: + name: npm-package + path: mcp/dist/npm-staging/ + + publish-npm: + needs: build-npm + if: startsWith(github.ref, 'refs/tags/mcp-v') + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - uses: actions/download-artifact@v4 + with: + name: npm-package + path: package/ + - uses: actions/setup-node@v4 + with: + node-version: '24' + registry-url: 'https://registry.npmjs.org' + # Uses NPM_TOKEN for initial publish. To switch to trusted publishing: + # 1. Configure OIDC at npmjs.com/package/@andrewleech/viperide-mcp/access + # 2. Delete the NPM_TOKEN secret from GitHub repo settings + # 3. Remove the NODE_AUTH_TOKEN env line below + - name: Publish to npm + working-directory: package + run: | + VERSION=$(node -p "require('./package.json').version") + TAG="latest" + if echo "$VERSION" | grep -q '-'; then TAG="next"; fi + npm publish --access public --provenance --tag "$TAG" + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + publish-release: + needs: build-dist + if: startsWith(github.ref, 'refs/tags/mcp-v') + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts/ + merge-multiple: false + - name: Collect release files + run: | + mkdir release + find artifacts/dist-* -type f \( -name '*.tar.gz' -o -name '*.mcpb' \) -exec cp {} release/ \; + ls -lh release/ + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: release/* + generate_release_notes: true diff --git a/.gitignore b/.gitignore index 35ff2d8..9734b24 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules/ .parcel-cache/ extra/ src/tools_vfs/lib/python_minifier/ +bun.lock diff --git a/eslint.config.mjs b/eslint.config.mjs index 94d041e..a786036 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -2,7 +2,7 @@ import globals from "globals" import pluginJs from "@eslint/js" export default [ - { ignores: ["build/", "src/websocket_relay.js"] }, + { ignores: ["build/", "src/websocket_relay.js", "mcp/"] }, { languageOptions: { globals: globals.browser }}, pluginJs.configs.recommended, { diff --git a/mcp/.gitignore b/mcp/.gitignore new file mode 100644 index 0000000..72b0791 --- /dev/null +++ b/mcp/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +bun.lock +package-lock.json +dist/ diff --git a/mcp/.mcpbignore b/mcp/.mcpbignore new file mode 100644 index 0000000..055c1a1 --- /dev/null +++ b/mcp/.mcpbignore @@ -0,0 +1,8 @@ +test/ +bun.lock +package-lock.json +install.sh +build-mcpb.sh +.mcpbignore +.gitignore +README.md diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 0000000..30e0d25 --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,128 @@ +# ViperIDE MCP Server + +An MCP (Model Context Protocol) server that gives Claude full remote control of [ViperIDE](https://viper-ide.org), a MicroPython/CircuitPython IDE. The IDE opens in your browser for direct interaction while Claude simultaneously controls it via MCP tools. + +## Architecture + +The MCP server acts as a bridge between Claude and ViperIDE running in your browser. It also provides a serial-to-WebSocket bridge so USB devices can be connected without browser permission dialogs. + +| Component | Role | Connection | +|---|---|---| +| **Claude** (Code/Desktop) | Sends MCP tool calls | stdio JSON-RPC to MCP server | +| **MCP Server** (Node.js) | Routes commands, serves IDE, bridges serial | localhost HTTP + WebSocket | +| **Browser** (ViperIDE) | IDE UI, executes commands on device | WS control channel (`/ws`) | +| **USB Device** (MicroPython) | Target hardware | Serial port via WS bridge (`/serial/...`) | + +The MCP server: +1. Serves the built ViperIDE on a random localhost port +2. Opens your default browser to the IDE +3. Maintains a WebSocket control channel for sending commands +4. Exposes IDE operations as MCP tools that Claude can call + +Both you and Claude can interact with the IDE simultaneously. + +## Install + +### Claude Desktop + +Download the `.mcpb` bundle for your platform from [Releases](https://github.com/andrewleech/ViperIDE/releases). In Claude Desktop, go to Settings > Extensions > Advanced Settings > Install Extension, then select the `.mcpb` file. + +### Claude Code + +```bash +claude mcp add viperIDE -- npx -y @andrewleech/viperide-mcp +``` + +### From source + +```bash +git clone https://github.com/andrewleech/ViperIDE.git +cd ViperIDE && npm install && python3 build.py +cd mcp && npm install +claude mcp add viperIDE -- node $(pwd)/src/index.js +``` + +## Available Tools + +### Connection +| Tool | Description | +|---|---| +| `viperIDE_get_status` | Connection status, device info, open file, editor state | +| `viperIDE_connect_device` | Connect via WebSocket (`ws`), virtual device (`vm`), or prompt user for USB/BLE | +| `viperIDE_connect_serial` | Connect to a USB serial MicroPython device via the local WebSocket bridge | +| `viperIDE_list_serial_ports` | List available USB serial ports with device details | +| `viperIDE_disconnect_device` | Disconnect current device | + +### File Operations (on device) +| Tool | Description | +|---|---| +| `viperIDE_list_files` | List all files and directories with sizes | +| `viperIDE_read_file` | Read file content | +| `viperIDE_write_file` | Write content to file (creates parent dirs) | +| `viperIDE_delete_file` | Delete a file | +| `viperIDE_delete_dir` | Delete a directory | +| `viperIDE_mkdir` | Create a directory | +| `viperIDE_create_file` | Create a new file and open it in the editor | + +### Editor +| Tool | Description | +|---|---| +| `viperIDE_open_file` | Open a device file in the editor | +| `viperIDE_get_editor` | Get current editor content and filename | +| `viperIDE_set_editor` | Replace editor content | +| `viperIDE_save_file` | Save editor content to device | + +### Execution +| Tool | Description | +|---|---| +| `viperIDE_run_file` | Run current file on device (non-blocking) | +| `viperIDE_stop` | Interrupt running script (Ctrl-C) | +| `viperIDE_reboot` | Reboot device (soft/hard/bootloader) | + +### Terminal +| Tool | Description | +|---|---| +| `viperIDE_read_terminal` | Read REPL output | +| `viperIDE_write_terminal` | Send input to REPL | +| `viperIDE_clear_terminal` | Clear terminal | + +### Packages +| Tool | Description | +|---|---| +| `viperIDE_install_package` | Install a MicroPython package by name or URL | + +## USB Serial Bridge + +The MCP server includes a serial-to-WebSocket bridge that lets Claude connect directly to USB serial MicroPython devices without requiring the browser's WebSerial permission picker. + +1. Use `viperIDE_list_serial_ports` to discover connected devices +2. Use `viperIDE_connect_serial` with the device path to connect + +The bridge opens the serial port at 115200 baud and fakes a WebREPL handshake so ViperIDE's existing WebSocket transport works unchanged. Multiple devices can be bridged simultaneously. + +Requires the `serialport` npm package (installed automatically with `npm install`). + +## Limitations + +- **Bluetooth connections** require the user to click the connect button in the browser (browser security policy requires a user gesture for device picker dialogs). +- **USB connections** can be made via the serial bridge (`viperIDE_connect_serial`) without user interaction, or via the browser's WebSerial API which requires a user gesture. +- **WebSocket and VM connections** can be initiated fully by Claude. +- Running scripts is fire-and-forget. Use `read_terminal` to monitor output and `stop` to interrupt. +- Only one browser tab should be connected to the MCP server at a time. + +## Development + +During development, you can run the MCP server directly: + +```bash +# Build ViperIDE first +npm install && python3 build.py + +# Install MCP deps +cd mcp && npm install + +# Run the server (logs to stderr, MCP protocol on stdio) +node mcp/src/index.js +``` + +The server picks a random port and opens the browser automatically. diff --git a/mcp/build-dist.sh b/mcp/build-dist.sh new file mode 100755 index 0000000..4b244b9 --- /dev/null +++ b/mcp/build-dist.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$SCRIPT_DIR" + +STAGING="$SCRIPT_DIR/dist/staging" +PLATFORM="${1:-$(uname -s | tr '[:upper:]' '[:lower:]')}" + +case "$PLATFORM" in + linux) MCPB_PLATFORM="linux" ;; + darwin) MCPB_PLATFORM="darwin" ;; + win32|windows|mingw*|msys*) MCPB_PLATFORM="win32" ;; + *) echo "Unknown platform: $PLATFORM"; exit 1 ;; +esac + +VERSION=$(node -p "require('./package.json').version") +echo "=== Building ViperIDE MCP v${VERSION} for ${MCPB_PLATFORM} ===" + +# Step 1: Build ViperIDE if needed +if [ ! -f "$REPO_ROOT/build/index.html" ]; then + echo "Building ViperIDE..." + cd "$REPO_ROOT" + npm install + python3 build.py +fi + +# Step 2: Clean and create staging directory +rm -rf "$STAGING" +mkdir -p "$STAGING/server" + +# Step 3: Copy server files +for f in index.js ide-server.js bridge.js serial-bridge.js; do + cp "$SCRIPT_DIR/src/$f" "$STAGING/server/" +done + +# Step 4: Copy pre-built ViperIDE assets (exclude source maps) +cp -r "$REPO_ROOT/build" "$STAGING/" +find "$STAGING/build" -name '*.map' -delete 2>/dev/null || true + +# Step 5: Create package.json for the distribution +cat > "$STAGING/package.json" << PKGJSON +{ + "name": "@andrewleech/viperide-mcp", + "version": "${VERSION}", + "description": "MCP server for controlling ViperIDE - a MicroPython/CircuitPython IDE", + "type": "module", + "main": "server/index.js", + "bin": { + "viperide-mcp": "server/index.js" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1", + "open": "^10.1.0", + "serialport": "^12.0.0", + "ws": "^8.18.0", + "zod": "^3.24.0" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/andrewleech/ViperIDE", + "directory": "mcp" + } +} +PKGJSON + +# Step 6: Install production dependencies +echo "Installing production dependencies..." +cd "$STAGING" +npm install --production 2>&1 | tail -3 + +# Step 7: Create tar.gz (for direct download / npm-like install) +TARBALL="$SCRIPT_DIR/dist/viperide-mcp-${VERSION}-${MCPB_PLATFORM}.tar.gz" +echo "Creating tarball..." +cd "$SCRIPT_DIR/dist" +tar czf "$TARBALL" -C staging . +echo " $TARBALL ($(du -h "$TARBALL" | cut -f1))" + +# Step 8: Generate platform-specific MCPB manifest and pack bundle +cat > "$STAGING/manifest.json" << MANIFEST +{ + "manifest_version": "0.3", + "name": "viperIDE", + "display_name": "ViperIDE", + "version": "${VERSION}", + "description": "MicroPython/CircuitPython IDE with full remote control. Connect to devices via USB serial, browse files, edit code, execute scripts, and interact with the REPL.", + "author": { + "name": "Andrew Leech & Volodymyr Shymanskyy" + }, + "license": "MIT", + "homepage": "https://viper-ide.org", + "repository": { + "url": "https://github.com/vshymanskyy/ViperIDE", + "type": "git" + }, + "server": { + "type": "node", + "entry_point": "server/index.js", + "mcp_config": { + "command": "node", + "args": ["\${__dirname}/server/index.js"], + "env": { + "VIPERIDE_BUILD_DIR": "\${__dirname}/build" + } + } + }, + "tools": [ + { "name": "viperIDE_get_status", "description": "Get IDE connection status, device info, and editor state" }, + { "name": "viperIDE_connect_serial", "description": "Connect to a MicroPython device via USB serial" }, + { "name": "viperIDE_list_serial_ports", "description": "List available USB serial ports with device details" }, + { "name": "viperIDE_list_files", "description": "List files and directories on the device" }, + { "name": "viperIDE_read_file", "description": "Read a file from the device" }, + { "name": "viperIDE_write_file", "description": "Write a file to the device" }, + { "name": "viperIDE_run_file", "description": "Execute the current file on the device" }, + { "name": "viperIDE_read_terminal", "description": "Read REPL output" } + ], + "tools_generated": true, + "compatibility": { + "platforms": ["$MCPB_PLATFORM"] + } +} +MANIFEST + +MCPB_FILE="$SCRIPT_DIR/dist/viperide-mcp-${VERSION}-${MCPB_PLATFORM}.mcpb" +echo "Packing MCPB bundle..." +npx @anthropic-ai/mcpb pack "$STAGING" "$MCPB_FILE" 2>&1 | tail -5 +echo " $MCPB_FILE ($(du -h "$MCPB_FILE" | cut -f1))" + +# Step 9: Validate +echo "" +npx @anthropic-ai/mcpb validate "$STAGING/manifest.json" 2>&1 + +echo "" +echo "=== Build complete ===" +echo "Tarball: $TARBALL" +echo "MCPB: $MCPB_FILE" +echo "" +echo "Install tarball with Claude Code:" +echo " tar xzf $(basename "$TARBALL") -C ~/.local/share/viperide-mcp" +echo " claude mcp add viperIDE -- node ~/.local/share/viperide-mcp/server/index.js" diff --git a/mcp/build-npm.sh b/mcp/build-npm.sh new file mode 100755 index 0000000..46eedc0 --- /dev/null +++ b/mcp/build-npm.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +STAGING="$SCRIPT_DIR/dist/npm-staging" + +VERSION=$(node -p "require('./package.json').version") +echo "=== Building @andrewleech/viperide-mcp v${VERSION} for npm ===" + +# Step 1: Build ViperIDE if needed +if [ ! -f "$REPO_ROOT/build/index.html" ]; then + echo "Building ViperIDE..." + cd "$REPO_ROOT" + npm install + python3 build.py +fi + +# Step 2: Clean and create staging directory +rm -rf "$STAGING" +mkdir -p "$STAGING/server" + +# Step 3: Copy server files +for f in index.js ide-server.js bridge.js serial-bridge.js; do + cp "$SCRIPT_DIR/src/$f" "$STAGING/server/" +done + +# Step 4: Copy pre-built ViperIDE assets (exclude source maps) +cp -r "$REPO_ROOT/build" "$STAGING/" +find "$STAGING/build" -name '*.map' -delete 2>/dev/null || true + +# Step 5: Copy README +cp "$SCRIPT_DIR/README.md" "$STAGING/README.md" + +# Step 6: Create package.json +cat > "$STAGING/package.json" << PKGJSON +{ + "name": "@andrewleech/viperide-mcp", + "version": "${VERSION}", + "description": "MCP server for controlling ViperIDE, a MicroPython/CircuitPython IDE. Connects to devices via USB serial, manages files, executes code, and provides full REPL access.", + "type": "module", + "main": "server/index.js", + "bin": { + "viperide-mcp": "server/index.js" + }, + "files": [ + "server/", + "build/", + "README.md" + ], + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1", + "open": "^10.1.0", + "serialport": "^12.0.0", + "ws": "^8.18.0", + "zod": "^3.24.0" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/andrewleech/ViperIDE", + "directory": "mcp" + }, + "homepage": "https://viper-ide.org", + "keywords": [ + "mcp", + "micropython", + "circuitpython", + "ide", + "viperide", + "serial", + "repl", + "embedded" + ] +} +PKGJSON + +echo "" +echo "=== npm package staged at: $STAGING ===" +echo "" +echo "To publish:" +echo " cd $STAGING" +echo " npm publish --access public" +echo "" +echo "To test locally:" +echo " npm install $STAGING" +echo "" +echo "After publishing, users install with:" +echo " claude mcp add viperIDE -- npx -y @andrewleech/viperide-mcp" diff --git a/mcp/install.sh b/mcp/install.sh new file mode 100755 index 0000000..4cac7b1 --- /dev/null +++ b/mcp/install.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +set -euo pipefail + +INSTALL_DIR="${VIPERIDE_MCP_DIR:-$HOME/.local/share/viperIDE-mcp}" +BRANCH="feature/mcp-server" +REPO="https://github.com/andrewleech/ViperIDE.git" + +echo "=== ViperIDE MCP Server Installer ===" +echo "" + +# Check required tools +missing="" +for cmd in node npm python3 git; do + if ! command -v "$cmd" &>/dev/null; then + missing="$missing $cmd" + fi +done +if [ -n "$missing" ]; then + echo "Error: required tools not found:$missing" >&2 + exit 1 +fi + +# Clone or update +if [ -d "$INSTALL_DIR/.git" ]; then + echo "Updating existing installation in $INSTALL_DIR ..." + cd "$INSTALL_DIR" + git fetch origin + git checkout "$BRANCH" + git pull origin "$BRANCH" +else + echo "Cloning ViperIDE ($BRANCH) to $INSTALL_DIR ..." + git clone --branch "$BRANCH" --single-branch "$REPO" "$INSTALL_DIR" + cd "$INSTALL_DIR" +fi + +# Install ViperIDE dependencies and build +echo "" +echo "Installing ViperIDE dependencies..." +npm install + +# python-minifier download in build.py needs requests +python3 -c "import requests" 2>/dev/null || { + echo "Installing Python requests module..." + pip3 install --user requests 2>/dev/null || pip install --user requests 2>/dev/null || { + echo "Warning: could not install Python 'requests' module. build.py may fail." >&2 + } +} + +echo "Building ViperIDE..." +python3 build.py + +# Install MCP server dependencies +echo "" +echo "Installing MCP server dependencies..." +cd "$INSTALL_DIR/mcp" +npm install + +MCP_CMD="node" +MCP_ARG="$INSTALL_DIR/mcp/src/index.js" + +echo "" +echo "=== Installation complete ===" +echo "" + +# Register with Claude Code if available +if command -v claude &>/dev/null; then + echo "Registering with Claude Code..." + claude mcp add viperIDE -- "$MCP_CMD" "$MCP_ARG" && { + echo "Done. Restart Claude Code to use ViperIDE tools." + } || { + echo "Auto-registration failed. Register manually:" + echo " claude mcp add viperIDE -- $MCP_CMD $MCP_ARG" + } +else + echo "Claude Code CLI not found. To register manually:" + echo " claude mcp add viperIDE -- $MCP_CMD $MCP_ARG" +fi + +echo "" +echo "For Claude Desktop, add this to your config:" +echo "" +echo " macOS: ~/Library/Application Support/Claude/claude_desktop_config.json" +echo " Linux: ~/.config/Claude/claude_desktop_config.json" +echo " Windows: %APPDATA%\\Claude\\claude_desktop_config.json" +echo "" +cat < { + if (this.activeWs) { + console.error('[ViperIDE MCP] New browser tab connected, replacing previous') + for (const [id, { reject, timeout }] of this.pending) { + clearTimeout(timeout) + reject(new Error('Another browser tab connected')) + } + this.pending.clear() + } + this.activeWs = ws + for (const resolve of this.connectWaiters) resolve() + this.connectWaiters = [] + console.error('[ViperIDE MCP] Browser connected') + } + + ideServer.onDisconnect = (ws) => { + if (this.activeWs === ws) { + this.activeWs = null + for (const [id, { reject, timeout }] of this.pending) { + clearTimeout(timeout) + reject(new Error('Browser disconnected')) + } + this.pending.clear() + console.error('[ViperIDE MCP] Browser disconnected') + } + } + + ideServer.onMessage = (_ws, data) => { + let msg + try { + msg = JSON.parse(data) + } catch { + return + } + + if (msg.jsonrpc === '2.0' && msg.id != null) { + const pending = this.pending.get(msg.id) + if (pending) { + clearTimeout(pending.timeout) + this.pending.delete(msg.id) + if (msg.error) { + pending.reject(new Error(msg.error.message || 'Unknown error')) + } else { + pending.resolve(msg.result) + } + } + } + + if (msg.type === 'event') { + console.error(`[ViperIDE MCP] Event: ${msg.event}`, msg.data || '') + } + } + } + + get isConnected() { + return this.activeWs !== null + } + + async waitForConnection(timeoutMs = 15000) { + if (this.activeWs) return + return new Promise((resolve, reject) => { + const waiter = () => { + clearTimeout(timer) + resolve() + } + const timer = setTimeout(() => { + this.connectWaiters = this.connectWaiters.filter(w => w !== waiter) + reject(new Error('Timed out waiting for ViperIDE browser to connect')) + }, timeoutMs) + this.connectWaiters.push(waiter) + }) + } + + async call(method, params = {}, timeoutMs = 30000) { + if (!this.activeWs) { + await this.waitForConnection() + } + + const id = this.nextId++ + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(id) + reject(new Error(`Timeout waiting for ${method} (${timeoutMs}ms)`)) + }, timeoutMs) + + this.pending.set(id, { resolve, reject, timeout }) + const sent = this.ideServer.send(this.activeWs, { + jsonrpc: '2.0', + id, + method, + params, + }) + if (!sent) { + clearTimeout(timeout) + this.pending.delete(id) + reject(new Error('Browser connection lost')) + } + }) + } +} diff --git a/mcp/src/ide-server.js b/mcp/src/ide-server.js new file mode 100644 index 0000000..48fc741 --- /dev/null +++ b/mcp/src/ide-server.js @@ -0,0 +1,116 @@ +import http from 'node:http' +import fs from 'node:fs' +import path from 'node:path' +import { WebSocketServer } from 'ws' + +const MIME_TYPES = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript', + '.mjs': 'text/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.wasm': 'application/wasm', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.gz': 'application/gzip', + '.tar': 'application/x-tar', +} + +export class IDEServer { + constructor(buildDir) { + this.buildDir = path.resolve(buildDir) + this.port = null + this.onMessage = null + this.onConnect = null + this.onDisconnect = null + this.serialBridge = null + + this.httpServer = http.createServer((req, res) => { + this._handleRequest(req, res) + }) + + this.wss = new WebSocketServer({ noServer: true }) + this.wss.on('connection', (ws) => { + if (this.onConnect) this.onConnect(ws) + + ws.on('message', (data) => { + if (this.onMessage) this.onMessage(ws, data.toString()) + }) + + ws.on('close', () => { + if (this.onDisconnect) this.onDisconnect(ws) + }) + }) + + this.httpServer.on('upgrade', (req, socket, head) => { + const pathname = new URL(req.url, 'http://localhost').pathname + + if (pathname === '/ws') { + this.wss.handleUpgrade(req, socket, head, (ws) => { + this.wss.emit('connection', ws, req) + }) + } else if (pathname.startsWith('/serial/') && this.serialBridge) { + this.wss.handleUpgrade(req, socket, head, (ws) => { + this.serialBridge.handleConnection(ws, req) + }) + } else { + socket.destroy() + } + }) + } + + async start(port = 0) { + return new Promise((resolve, reject) => { + this.httpServer.once('error', reject) + this.httpServer.listen(port, '127.0.0.1', () => { + const addr = this.httpServer.address() + this.port = addr.port + resolve(addr.port) + }) + }) + } + + async stop() { + return new Promise((resolve) => { + this.wss.close() + this.httpServer.close(resolve) + }) + } + + send(ws, msg) { + if (ws.readyState === 1) { + ws.send(typeof msg === 'string' ? msg : JSON.stringify(msg)) + return true + } + return false + } + + async _handleRequest(req, res) { + let urlPath = new URL(req.url, 'http://localhost').pathname + if (urlPath === '/') urlPath = '/index.html' + + const filePath = path.join(this.buildDir, urlPath) + + if (!filePath.startsWith(this.buildDir + path.sep) && filePath !== this.buildDir) { + res.writeHead(403) + res.end('Forbidden') + return + } + + try { + const content = await fs.promises.readFile(filePath) + const ext = path.extname(filePath) + const mime = MIME_TYPES[ext] || 'application/octet-stream' + res.writeHead(200, { + 'Content-Type': mime, + 'Content-Length': content.length, + 'Cache-Control': 'no-cache', + }) + res.end(content) + } catch { + res.writeHead(404) + res.end('Not found') + } + } +} diff --git a/mcp/src/index.js b/mcp/src/index.js new file mode 100644 index 0000000..edcefaa --- /dev/null +++ b/mcp/src/index.js @@ -0,0 +1,305 @@ +#!/usr/bin/env node + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { z } from 'zod' +import open from 'open' +import path from 'node:path' +import fs from 'node:fs' +import { fileURLToPath } from 'node:url' + +import { IDEServer } from './ide-server.js' +import { Bridge } from './bridge.js' +import { SerialBridge } from './serial-bridge.js' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +// Resolve the build directory: env var (MCPB bundle), sibling dir (tarball), or repo layout +const buildDir = process.env.VIPERIDE_BUILD_DIR + || [path.resolve(__dirname, '../build'), path.resolve(__dirname, '../../build')] + .find(d => fs.existsSync(path.join(d, 'index.html'))) + +if (!buildDir) { + console.error('[ViperIDE MCP] Build directory not found.') + console.error('[ViperIDE MCP] Run "python3 build.py" in the ViperIDE root first.') + process.exit(1) +} + +// IDE server and bridge are created lazily on first tool call so the MCP +// transport handshake completes immediately without waiting for HTTP listen. +let ideServer = null +let bridge = null +let serialBridge = null +let idePort = null +let ideUrl = null +let initPromise = null + +async function ensureInit() { + if (ideServer) return + if (initPromise) return initPromise + initPromise = (async () => { + ideServer = new IDEServer(buildDir) + bridge = new Bridge(ideServer) + serialBridge = new SerialBridge() + ideServer.serialBridge = serialBridge + idePort = await ideServer.start() + ideUrl = `http://localhost:${idePort}/?mcp=1` + console.error(`[ViperIDE MCP] IDE server ready at ${ideUrl}`) + })() + return initPromise +} + +let browserOpening = false +async function ensureBrowser() { + await ensureInit() + if (bridge.isConnected || browserOpening) return + browserOpening = true + console.error('[ViperIDE MCP] Opening browser...') + open(ideUrl) + .catch(err => console.error('[ViperIDE MCP] Failed to open browser:', err)) + .finally(() => { browserOpening = false }) +} + +function textResult(data) { + const text = typeof data === 'string' ? data : JSON.stringify(data) + return { content: [{ type: 'text', text }] } +} + +function errorResult(msg) { + return { content: [{ type: 'text', text: msg }], isError: true } +} + +const mcp = new McpServer({ + name: 'viperIDE', + version: '0.1.0', +}) + +// get_status is handled separately so it works without the browser +mcp.tool( + 'viperIDE_get_status', + 'Get ViperIDE connection status, device info, and editor state', + {}, + async () => { + await ensureInit() + if (!bridge.isConnected) { + return textResult({ + browserConnected: false, + connected: false, + deviceInfo: null, + message: 'ViperIDE browser tab is not open yet. Call any other tool to launch it.', + }) + } + try { + const result = await bridge.call('get_status') + return textResult({ browserConnected: true, ...result }) + } catch (err) { + return errorResult(err.message) + } + } +) + +const tools = [ + { + name: 'viperIDE_connect_device', + description: 'Connect to a MicroPython device. Use "ws" with a URL for WebSocket, "vm" for the built-in virtual device. USB and Bluetooth require the user to click the connect button in the browser. Non-blocking; the device handshake takes 10-20 seconds. Poll get_status every 5 seconds until connected is true; do not assume failure before 30 seconds.', + schema: { + type: z.enum(['ws', 'ble', 'usb', 'vm']).describe('Connection type'), + url: z.string().optional().describe('WebSocket URL, e.g. ws://192.168.1.1:8266 (required for ws type)'), + password: z.string().optional().describe('WebREPL password (for ws type)'), + }, + method: 'connect_device', + }, + { + name: 'viperIDE_disconnect_device', + description: 'Disconnect from the current device', + schema: {}, + method: 'disconnect_device', + }, + { + name: 'viperIDE_list_files', + description: 'List all files and directories on the connected MicroPython device', + schema: {}, + method: 'list_files', + }, + { + name: 'viperIDE_read_file', + description: 'Read a file from the connected MicroPython device', + schema: { path: z.string().describe('File path on device, e.g. /main.py') }, + method: 'read_file', + }, + { + name: 'viperIDE_write_file', + description: 'Write content to a file on the connected MicroPython device. Creates parent directories as needed. Note: this writes to the device filesystem only; the editor is not updated. Use open_file after writing to refresh the editor, or use set_editor + save_file to edit and save in one flow.', + schema: { + path: z.string().describe('File path on device, e.g. /main.py'), + content: z.string().describe('File content to write'), + }, + method: 'write_file', + }, + { + name: 'viperIDE_delete_file', + description: 'Delete a file from the connected MicroPython device', + schema: { path: z.string().describe('File path to delete') }, + method: 'delete_file', + }, + { + name: 'viperIDE_delete_dir', + description: 'Delete a directory from the connected MicroPython device', + schema: { path: z.string().describe('Directory path to delete') }, + method: 'delete_dir', + }, + { + name: 'viperIDE_mkdir', + description: 'Create a directory on the connected MicroPython device', + schema: { path: z.string().describe('Directory path to create') }, + method: 'mkdir', + }, + { + name: 'viperIDE_open_file', + description: 'Open a file from the device in the ViperIDE editor', + schema: { path: z.string().describe('File path on device to open') }, + method: 'open_file', + }, + { + name: 'viperIDE_get_editor', + description: 'Get the current content of the ViperIDE editor and the open filename', + schema: {}, + method: 'get_editor', + }, + { + name: 'viperIDE_set_editor', + description: 'Replace the content of the ViperIDE editor', + schema: { content: z.string().describe('New editor content') }, + method: 'set_editor', + }, + { + name: 'viperIDE_save_file', + description: 'Save the current editor content to the connected device. Optionally provide a filename for untitled files.', + schema: { filename: z.string().optional().describe('Filename for untitled files, e.g. main.py') }, + method: 'save_file', + }, + { + name: 'viperIDE_run_file', + description: 'Execute the current EDITOR content on the device via raw REPL. Important: this runs whatever is shown in the editor, not what is on the device filesystem. To run new code: set_editor with the code, then run_file. Or write_file then open_file then run_file. Non-blocking; use read_terminal to monitor output, stop to interrupt.', + schema: {}, + method: 'run_file', + }, + { + name: 'viperIDE_stop', + description: 'Stop the currently running script on the device (sends Ctrl-C)', + schema: {}, + method: 'stop', + }, + { + name: 'viperIDE_reboot', + description: 'Reboot the connected MicroPython device', + schema: { mode: z.enum(['soft', 'hard', 'bootloader']).default('hard').describe('Reboot mode') }, + method: 'reboot', + }, + { + name: 'viperIDE_read_terminal', + description: 'Read the current content of the terminal/REPL output', + schema: {}, + method: 'read_terminal', + transform: (result) => result.content, + }, + { + name: 'viperIDE_write_terminal', + description: 'Send a single short REPL command (a newline is appended automatically). Only for quick interactive commands like print() or help(). For multi-line code or scripts, use write_file + run_file instead.', + schema: { text: z.string().describe('Single REPL command, e.g. "print(1+1)"') }, + method: 'write_terminal', + }, + { + name: 'viperIDE_clear_terminal', + description: 'Clear the terminal/REPL output', + schema: {}, + method: 'clear_terminal', + }, + { + name: 'viperIDE_install_package', + description: 'Install a MicroPython package on the connected device', + schema: { + name: z.string().describe('Package name or URL'), + version: z.string().optional().describe('Specific version to install'), + }, + method: 'install_package', + }, + { + name: 'viperIDE_create_file', + description: 'Create a new file on the connected device and open it in the editor', + schema: { + path: z.string().describe('Parent directory, e.g. /'), + name: z.string().describe('Filename to create, e.g. main.py'), + }, + method: 'create_file', + }, + { + name: 'viperIDE_close_file', + description: 'Close a file tab in the editor. Does not delete the file from the device.', + schema: { path: z.string().describe('File path to close, e.g. /main.py') }, + method: 'close_file', + }, +] + +for (const { name, description, schema, method, transform } of tools) { + mcp.tool(name, description, schema, async (params) => { + await ensureBrowser() + try { + const result = await bridge.call(method, params) + return textResult(transform ? transform(result) : result) + } catch (err) { + return errorResult(err.message) + } + }) +} + +mcp.tool( + 'viperIDE_list_serial_ports', + 'List available USB serial ports. Returns likelyPorts (known MicroPython-capable boards by VID: Raspberry Pi, Adafruit, Espressif, FTDI, etc.) and allPorts. On Windows, ports appear as COMx; on Linux as /dev/ttyACMx or /dev/serial/by-id/...', + {}, + async () => { + await ensureInit() + try { + const result = await serialBridge.listPorts() + return textResult(result) + } catch (err) { + return errorResult(err.message) + } + } +) + +mcp.tool( + 'viperIDE_connect_serial', + 'Connect to a MicroPython device via USB serial through the local WebSocket bridge. Use list_serial_ports first to find the port path. On Windows use COMx format, on Linux use /dev/ttyACMx or /dev/serial/by-id/... paths. Non-blocking; the device handshake takes 10-20 seconds. Poll get_status every 5 seconds until connected is true; do not assume failure before 30 seconds.', + { + port_path: z.string().describe('Serial port path, e.g. COM4 or /dev/serial/by-id/usb-MicroPython_Board_...'), + }, + async ({ port_path }) => { + await ensureBrowser() + try { + const wsUrl = `ws://localhost:${idePort}/serial/${encodeURIComponent(port_path)}` + // Fire and forget - connection + raw mode handshake can take 20+ seconds + // which exceeds Claude Desktop's API timeout. Return immediately. + bridge.call('connect_device', { type: 'ws', url: wsUrl }) + .catch(err => console.error('[ViperIDE MCP] connect_serial error:', err.message)) + return textResult({ + url: wsUrl, + ok: true, + message: 'Connection initiated. Use get_status to check when the device is ready.', + }) + } catch (err) { + return errorResult(err.message) + } + } +) + +for (const sig of ['SIGINT', 'SIGTERM']) { + process.on(sig, async () => { + if (ideServer) await ideServer.stop() + process.exit(0) + }) +} + +const transport = new StdioServerTransport() +await mcp.connect(transport) +console.error('[ViperIDE MCP] MCP server ready') diff --git a/mcp/src/serial-bridge.js b/mcp/src/serial-bridge.js new file mode 100644 index 0000000..5c88934 --- /dev/null +++ b/mcp/src/serial-bridge.js @@ -0,0 +1,144 @@ +let SerialPort = null + +async function getSerialPort() { + if (!SerialPort) { + const mod = await import('serialport') + SerialPort = mod.SerialPort + } + return SerialPort +} + +export class SerialBridge { + + async handleConnection(ws, req) { + const SP = await getSerialPort() + const url = new URL(req.url, 'http://localhost') + const encoded = url.pathname.slice('/serial/'.length) + const devicePath = decodeURIComponent(encoded) + + console.error(`[ViperIDE MCP] Serial bridge opening: ${devicePath}`) + + let port + try { + port = new SP({ + path: devicePath, + baudRate: 115200, + autoOpen: false, + }) + } catch (err) { + console.error(`[ViperIDE MCP] Serial port create error: ${err.message}`) + ws.close(1011, err.message.slice(0, 123)) + return + } + + let handshakeDone = false + let closed = false + const preHandshakeBuffer = [] + + const cleanup = (reason) => { + if (closed) return + closed = true + console.error(`[ViperIDE MCP] Serial bridge closed: ${devicePath} (${reason})`) + if (port.isOpen) { + port.close(() => {}) + } + if (ws.readyState <= 1) { + ws.close(1000, reason.slice(0, 123)) + } + } + + port.on('error', (err) => { + cleanup(`serial error: ${err.message}`) + }) + + port.on('close', () => { + cleanup('serial port closed') + }) + + ws.on('close', () => { + cleanup('websocket closed') + }) + + ws.on('error', (err) => { + cleanup(`websocket error: ${err.message}`) + }) + + port.open((err) => { + if (err) { + console.error(`[ViperIDE MCP] Serial port open error: ${err.message}`) + ws.close(1011, err.message.slice(0, 123)) + return + } + + // Buffer serial data immediately so nothing is lost before handshake + port.on('data', (chunk) => { + if (closed) return + if (!handshakeDone) { + preHandshakeBuffer.push(chunk) + } else if (ws.readyState === 1) { + ws.send(chunk) + } + }) + + ws.send('Password: ') + + ws.on('message', (data) => { + if (closed) return + + if (!handshakeDone) { + handshakeDone = true + ws.send('\r\nWebREPL connected\r\n') + + // Flush any data received before handshake completed + for (const chunk of preHandshakeBuffer) { + if (ws.readyState === 1) ws.send(chunk) + } + preHandshakeBuffer.length = 0 + return + } + + const buf = Buffer.isBuffer(data) ? data : Buffer.from(data) + port.write(buf, (err) => { + if (err) cleanup(`serial write error: ${err.message}`) + }) + }) + }) + } + + async listPorts() { + const SP = await getSerialPort() + const ports = await SP.list() + const allPorts = ports.map((p) => ({ + path: p.path, + manufacturer: p.manufacturer || null, + serialNumber: p.serialNumber || null, + productId: p.productId || null, + vendorId: p.vendorId || null, + pnpId: p.pnpId || null, + })) + + // Known USB VID/PID combinations for MicroPython-capable boards + const knownVids = { + 'f055': 'MicroPython', // MicroPython USB VID + '2e8a': 'Raspberry Pi', // Raspberry Pi (Pico, Pico W, Pico 2) + '239a': 'Adafruit', // Adafruit CircuitPython boards + '303a': 'Espressif', // ESP32-S2/S3 native USB + '1a86': 'WCH', // CH340/CH9102 (common on ESP32 boards) + '10c4': 'Silicon Labs', // CP210x (common on ESP32 boards) + '0403': 'FTDI', // FTDI (common USB-serial adapter) + } + + const likelyPorts = [] + for (const p of allPorts) { + const mfr = (p.manufacturer || '').toLowerCase() + const vid = (p.vendorId || '').toLowerCase() + if (mfr.includes('micropython') || mfr.includes('micro python') + || mfr.includes('circuitpython') + || vid in knownVids) { + likelyPorts.push({ ...p, knownAs: knownVids[vid] || null }) + } + } + + return { likelyPorts, allPorts } + } +} diff --git a/mcp/test/e2e.js b/mcp/test/e2e.js new file mode 100644 index 0000000..0184417 --- /dev/null +++ b/mcp/test/e2e.js @@ -0,0 +1,499 @@ +#!/usr/bin/env node + +/** + * End-to-end test for the ViperIDE MCP server. + * + * Usage: + * node mcp/test/e2e.js # VM-only test + * node mcp/test/e2e.js ws://192.168.0.143:8266 test # WebREPL device test + * + * The test spawns the MCP server, waits for the browser to connect, + * then exercises every MCP tool and reports pass/fail. + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const SERVER_PATH = path.resolve(__dirname, '../src/index.js') + +const wsUrl = process.argv[2] || null +const wsPass = process.argv[3] || null +const serialPath = process.argv[4] || null + +let passed = 0 +let failed = 0 +const results = [] + +async function test(name, fn) { + try { + const result = await fn() + passed++ + results.push({ name, status: 'PASS', detail: null }) + console.log(` PASS ${name}`) + return result + } catch (err) { + failed++ + results.push({ name, status: 'FAIL', detail: err.message }) + console.log(` FAIL ${name}: ${err.message}`) + return null + } +} + +function assert(condition, msg) { + if (!condition) throw new Error(msg || 'Assertion failed') +} + +function assertIncludes(obj, key, msg) { + if (!(key in obj)) throw new Error(msg || `Missing key: ${key}`) +} + +async function callTool(client, name, args = {}) { + const result = await client.callTool({ name, arguments: args }) + if (result.isError) { + const text = result.content?.[0]?.text || 'Unknown error' + throw new Error(`Tool error: ${text}`) + } + const text = result.content?.[0]?.text + if (!text) return {} + try { + return JSON.parse(text) + } catch { + return { _raw: text } + } +} + +async function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +async function waitForBrowser(client, timeoutMs = 30000) { + const start = Date.now() + while (Date.now() - start < timeoutMs) { + try { + const status = await callTool(client, 'viperIDE_get_status') + if (status && 'connected' in status) return status + } catch { + // Bridge not ready yet + } + await sleep(1000) + } + throw new Error('Timeout waiting for browser to connect') +} + +async function waitForDevice(client, timeoutMs = 30000) { + const start = Date.now() + while (Date.now() - start < timeoutMs) { + const status = await callTool(client, 'viperIDE_get_status') + if (status.connected) return status + await sleep(1000) + } + throw new Error('Timeout waiting for device to connect') +} + +async function runVMTests(client) { + console.log('\n--- Phase 1: VM Device Tests ---\n') + + await test('connect_device (vm)', async () => { + const r = await callTool(client, 'viperIDE_connect_device', { type: 'vm' }) + assert(r.ok, 'Expected ok:true') + }) + + // VM (WASM MicroPython) takes time to load and initialize + await test('get_status (connected)', async () => { + const r = await waitForDevice(client, 30000) + assert(r.connected === true, `Expected connected:true, got ${r.connected}`) + }) + + await test('list_files', async () => { + const r = await callTool(client, 'viperIDE_list_files') + assertIncludes(r, 'files', 'Missing files array') + assert(Array.isArray(r.files), 'files should be an array') + }) + + await test('write_file', async () => { + const r = await callTool(client, 'viperIDE_write_file', { + path: '/test_e2e.py', + content: "print('hello from e2e')\n", + }) + assert(r.ok, 'Expected ok:true') + }) + + await test('read_file', async () => { + const r = await callTool(client, 'viperIDE_read_file', { path: '/test_e2e.py' }) + assert(r.content.includes("hello from e2e"), `Unexpected content: ${r.content}`) + }) + + await test('open_file', async () => { + const r = await callTool(client, 'viperIDE_open_file', { path: '/test_e2e.py' }) + assert(r.ok, 'Expected ok:true') + }) + + await sleep(500) + + await test('get_editor', async () => { + const r = await callTool(client, 'viperIDE_get_editor') + assert(r.content !== null, 'Editor content is null') + assert(r.content.includes("hello from e2e"), `Editor missing expected content`) + }) + + await test('set_editor', async () => { + const r = await callTool(client, 'viperIDE_set_editor', { + content: "print('modified by e2e')\n", + }) + assert(r.ok, 'Expected ok:true') + }) + + await test('save_file', async () => { + const r = await callTool(client, 'viperIDE_save_file') + assert(r.ok, 'Expected ok:true') + }) + + await sleep(500) + + await test('read_file (after save)', async () => { + const r = await callTool(client, 'viperIDE_read_file', { path: '/test_e2e.py' }) + assert(r.content.includes("modified by e2e"), `Save did not persist: ${r.content}`) + }) + + await test('run_file', async () => { + const r = await callTool(client, 'viperIDE_run_file') + assert(r.ok, 'Expected ok:true') + }) + + await sleep(2000) + + await test('read_terminal', async () => { + const r = await callTool(client, 'viperIDE_read_terminal') + assert(typeof r._raw === 'string' || typeof r.content === 'string', + 'Expected terminal content') + }) + + await test('clear_terminal', async () => { + const r = await callTool(client, 'viperIDE_clear_terminal') + assert(r.ok, 'Expected ok:true') + }) + + await test('mkdir', async () => { + const r = await callTool(client, 'viperIDE_mkdir', { path: '/test_dir' }) + assert(r.ok, 'Expected ok:true') + }) + + await test('delete_dir', async () => { + const r = await callTool(client, 'viperIDE_delete_dir', { path: '/test_dir' }) + assert(r.ok, 'Expected ok:true') + }) + + await test('delete_file', async () => { + const r = await callTool(client, 'viperIDE_delete_file', { path: '/test_e2e.py' }) + assert(r.ok, 'Expected ok:true') + }) + + await test('reboot (soft)', async () => { + const r = await callTool(client, 'viperIDE_reboot', { mode: 'soft' }) + assert(r.ok, 'Expected ok:true') + }) + + await sleep(1000) + + await test('disconnect_device', async () => { + const r = await callTool(client, 'viperIDE_disconnect_device') + assert(r.ok, 'Expected ok:true') + }) + + await test('get_status (disconnected)', async () => { + const r = await callTool(client, 'viperIDE_get_status') + assert(r.connected === false, `Expected connected:false, got ${r.connected}`) + }) +} + +async function runWebREPLTests(client, url, password) { + console.log(`\n--- Phase 2: WebREPL Device Tests (${url}) ---\n`) + + await test('connect_device (ws)', async () => { + const r = await callTool(client, 'viperIDE_connect_device', { + type: 'ws', + url, + password, + }) + assert(r.ok, 'Expected ok:true') + }) + + // WebREPL connection + device interrogation takes time + await test('get_status (ws connected)', async () => { + const r = await waitForDevice(client, 30000) + assert(r.connected === true, `Expected connected:true, got ${r.connected}`) + if (r.deviceInfo) { + console.log(` Device: ${r.deviceInfo.machine || 'unknown'}`) + console.log(` Firmware: ${r.deviceInfo.version || 'unknown'}`) + } + }) + + await test('list_files (ws)', async () => { + const r = await callTool(client, 'viperIDE_list_files') + assertIncludes(r, 'files', 'Missing files array') + console.log(` ${r.files.length} files/dirs found`) + if (r.fsSize) console.log(` FS: ${r.fsUsed}/${r.fsSize} bytes used`) + }) + + await test('write_file (ws)', async () => { + const r = await callTool(client, 'viperIDE_write_file', { + path: '/test_e2e.py', + content: "import sys\nprint('e2e', sys.platform)\n", + }) + assert(r.ok, 'Expected ok:true') + }) + + await test('read_file (ws)', async () => { + const r = await callTool(client, 'viperIDE_read_file', { path: '/test_e2e.py' }) + assert(r.content.includes('e2e'), `Unexpected content: ${r.content}`) + }) + + await test('open_file (ws)', async () => { + const r = await callTool(client, 'viperIDE_open_file', { path: '/test_e2e.py' }) + assert(r.ok, 'Expected ok:true') + }) + + await sleep(500) + + await test('run_file (ws)', async () => { + const r = await callTool(client, 'viperIDE_run_file') + assert(r.ok, 'Expected ok:true') + }) + + await sleep(3000) + + await test('read_terminal (ws)', async () => { + const r = await callTool(client, 'viperIDE_read_terminal') + const text = r._raw || r.content || '' + console.log(` Terminal (last 200 chars): ${text.slice(-200).replace(/\n/g, '\\n')}`) + }) + + await test('write_terminal (ws)', async () => { + const r = await callTool(client, 'viperIDE_write_terminal', { + text: "print('repl_echo_test')\r\n", + }) + assert(r.ok, 'Expected ok:true') + }) + + await sleep(1000) + + await test('read_terminal after write (ws)', async () => { + const r = await callTool(client, 'viperIDE_read_terminal') + const text = r._raw || r.content || '' + assert(text.includes('repl_echo_test'), `REPL echo not found in terminal`) + }) + + await test('delete_file (ws)', async () => { + const r = await callTool(client, 'viperIDE_delete_file', { path: '/test_e2e.py' }) + assert(r.ok, 'Expected ok:true') + }) + + await test('reboot soft (ws)', async () => { + const r = await callTool(client, 'viperIDE_reboot', { mode: 'soft' }) + assert(r.ok, 'Expected ok:true') + }) + + await sleep(2000) + + await test('disconnect_device (ws)', async () => { + const r = await callTool(client, 'viperIDE_disconnect_device') + assert(r.ok, 'Expected ok:true') + }) +} + +async function runSerialBridgeTests(client, portPath) { + console.log(`\n--- Phase 2b: Serial Bridge Tests (${portPath}) ---\n`) + + await test('list_serial_ports', async () => { + const r = await callTool(client, 'viperIDE_list_serial_ports') + assertIncludes(r, 'allPorts', 'Missing allPorts') + assert(Array.isArray(r.allPorts), 'allPorts should be array') + console.log(` ${r.likelyPorts.length} likely ports, ${r.allPorts.length} total`) + for (const p of r.likelyPorts) { + console.log(` - ${p.path} (${p.knownAs || p.manufacturer})`) + } + }) + + await test('connect_serial', async () => { + const r = await callTool(client, 'viperIDE_connect_serial', { port_path: portPath }) + assert(r.ok || r.url, `Expected ok or url, got: ${JSON.stringify(r)}`) + if (r.url) console.log(` Bridge URL: ${r.url}`) + }) + + await test('get_status (serial connected)', async () => { + const r = await waitForDevice(client, 30000) + assert(r.connected === true, `Expected connected:true, got ${r.connected}`) + if (r.deviceInfo) { + console.log(` Device: ${r.deviceInfo.machine || 'unknown'}`) + console.log(` Firmware: ${r.deviceInfo.version || 'unknown'}`) + } + }) + + // Detect writable root: STM32 boards use /flash/, others use / + let fsRoot = '/' + await test('list_files (serial)', async () => { + const r = await callTool(client, 'viperIDE_list_files') + assertIncludes(r, 'files', 'Missing files') + console.log(` ${r.files.length} files/dirs`) + const hasFlash = r.files.some(f => f.path === '/flash' && f.type === 'dir') + if (hasFlash) fsRoot = '/flash/' + console.log(` Writable root: ${fsRoot}`) + }) + + const testFile = fsRoot + 'test_e2e.py' + + await test('write_file (serial)', async () => { + const r = await callTool(client, 'viperIDE_write_file', { + path: testFile, + content: "import sys\nprint('serial_e2e', sys.platform)\n", + }) + assert(r.ok, 'Expected ok:true') + }) + + await test('read_file (serial)', async () => { + const r = await callTool(client, 'viperIDE_read_file', { path: testFile }) + assert(r.content.includes('serial_e2e'), `Unexpected content: ${r.content}`) + }) + + await test('open_file (serial)', async () => { + await callTool(client, 'viperIDE_open_file', { path: testFile }) + }) + + await sleep(500) + + await test('run_file (serial)', async () => { + await callTool(client, 'viperIDE_run_file') + }) + + await sleep(3000) + + await test('read_terminal (serial)', async () => { + const r = await callTool(client, 'viperIDE_read_terminal') + const text = r._raw || r.content || '' + console.log(` Terminal (last 200 chars): ${text.slice(-200).replace(/\n/g, '\\n')}`) + }) + + await test('write_terminal (serial)', async () => { + await callTool(client, 'viperIDE_write_terminal', { text: "print('repl_serial_test')\r\n" }) + }) + + await sleep(1500) + + await test('read_terminal after write (serial)', async () => { + const r = await callTool(client, 'viperIDE_read_terminal') + const text = r._raw || r.content || '' + assert(text.includes('repl_serial_test'), `REPL echo not found in terminal`) + }) + + await test('delete_file (serial)', async () => { + await callTool(client, 'viperIDE_delete_file', { path: testFile }) + }) + + await test('disconnect_device (serial)', async () => { + await callTool(client, 'viperIDE_disconnect_device') + }) +} + +async function runErrorTests(client) { + console.log('\n--- Phase 3: Error Handling Tests ---\n') + + await test('read_file with no device throws', async () => { + try { + await callTool(client, 'viperIDE_read_file', { path: '/nope.py' }) + throw new Error('Should have thrown') + } catch (err) { + assert(err.message.includes('No device connected') || err.message.includes('not connected'), + `Unexpected error: ${err.message}`) + } + }) + + await test('run_file with no device throws', async () => { + try { + await callTool(client, 'viperIDE_run_file') + throw new Error('Should have thrown') + } catch (err) { + assert(err.message.includes('No device connected') || err.message.includes('not connected'), + `Unexpected error: ${err.message}`) + } + }) + + await test('connect_device ws without url throws', async () => { + try { + await callTool(client, 'viperIDE_connect_device', { type: 'ws' }) + throw new Error('Should have thrown') + } catch (err) { + assert(err.message.includes('URL is required'), + `Unexpected error: ${err.message}`) + } + }) + + await test('connect_device usb returns user action required', async () => { + const r = await callTool(client, 'viperIDE_connect_device', { type: 'usb' }) + assert(r.requiresUserAction === true, 'Expected requiresUserAction:true') + }) +} + +// --- Main --- + +console.log('ViperIDE MCP Server - End-to-End Test') +console.log('=====================================\n') + +// Ensure the server can open a browser on the local display +const env = { ...process.env } +if (!env.DISPLAY || env.DISPLAY.startsWith('localhost')) { + env.DISPLAY = ':0' +} + +const transport = new StdioClientTransport({ + command: process.execPath, + args: [SERVER_PATH], + stderr: 'pipe', + env, +}) + +// Forward server stderr for diagnostics +transport.stderr?.on('data', (chunk) => { + const line = chunk.toString().trim() + if (line) console.log(` [server] ${line}`) +}) + +const client = new Client({ name: 'e2e-test', version: '0.1.0' }) +await client.connect(transport) + +console.log('MCP client connected. Waiting for browser...\n') +await waitForBrowser(client) +console.log('Browser connected.\n') + +// List available tools +const { tools } = await client.listTools() +console.log(`Available tools: ${tools.length}`) +console.log(tools.map(t => ` - ${t.name}`).join('\n')) + +await runVMTests(client) + +if (wsUrl) { + await runWebREPLTests(client, wsUrl, wsPass) +} + +if (serialPath) { + await runSerialBridgeTests(client, serialPath) +} + +await runErrorTests(client) + +// Summary +console.log('\n=====================================') +console.log(`Results: ${passed} passed, ${failed} failed out of ${passed + failed} tests`) +if (failed > 0) { + console.log('\nFailures:') + for (const r of results.filter(r => r.status === 'FAIL')) { + console.log(` - ${r.name}: ${r.detail}`) + } +} +console.log('=====================================\n') + +await client.close() +process.exit(failed > 0 ? 1 : 0) diff --git a/src/app.js b/src/app.js index 01ceac6..d4eb6e1 100644 --- a/src/app.js +++ b/src/app.js @@ -38,6 +38,8 @@ import { splitPath, sleep, fetchJSON, getUserUID, getScreenInfo, IdleMonitor, getCssPropertyValue, QSA, QS, QID, iOS, sanitizeHTML, isRunningStandalone, sizeFmt, indicateActivity, setupTabs, report } from './utils.js' +import { initControlClient } from './control_client.js' + import { library, dom } from '@fortawesome/fontawesome-svg-core' import { faUsb, faBluetoothB } from '@fortawesome/free-brands-svg-icons' import { faLink, faBars, faDownload, faCirclePlay, faCircleStop, faFolder, faFile, faFileCircleExclamation, faCubes, faGear, @@ -1197,6 +1199,21 @@ export function applyTranslation() { await connectDevice('ws') } + // MCP control client: enabled when served by the MCP server (?mcp=1) + if (urlParams.has('mcp')) { + initControlClient({ + connectDevice, disconnectDevice, createNewFile, + removeFile, removeDir, fileClick, saveCurrentFile, runCurrentFile, + reboot, clearTerminal, installPkg, MpRawMode, + getPort: () => port, + getEditor: () => editor, + getEditorFn: () => editorFn, + getTerm: () => term, + getDevInfo: () => devInfo, + isRunning: () => isInRunMode, + }) + } + })(); /* diff --git a/src/control_client.js b/src/control_client.js new file mode 100644 index 0000000..7c5462f --- /dev/null +++ b/src/control_client.js @@ -0,0 +1,339 @@ +/* + * SPDX-FileCopyrightText: 2024 Volodymyr Shymanskyy + * SPDX-License-Identifier: MIT + * + * The software is provided "as is", without any warranties or guarantees (explicit or implied). + * This includes no assurances about being fit for any specific purpose. + */ + +/* + * MCP Control Client + * + * Browser-side WebSocket client that enables programmatic control + * of ViperIDE from an MCP server. Connects to ws://localhost:PORT/ws + * when the IDE is loaded with ?mcp query parameter. + */ + +import { splitPath, Mutex } from './utils.js' + +const utf8Decoder = new TextDecoder('utf-8', { fatal: false }) + +export function initControlClient(api) { + const wsUrl = `ws://${window.location.host}/ws` + let ws = null + let reconnectDelay = 2000 + + function connect() { + ws = new WebSocket(wsUrl) + + ws.onopen = () => { + console.log('[MCP] Control channel connected') + reconnectDelay = 2000 + send({ type: 'hello', version: '0.1.0' }) + } + + ws.onclose = () => { + console.log('[MCP] Control channel disconnected, reconnecting...') + setTimeout(connect, reconnectDelay) + reconnectDelay = Math.min(reconnectDelay * 2, 60000) + } + + ws.onerror = () => { + // onclose will fire after this + } + + ws.onmessage = async (event) => { + let msg + try { + msg = JSON.parse(event.data) + } catch { + return + } + + if (!msg.method || !msg.id) return + + try { + const result = await dispatch(msg.method, msg.params || {}) + send({ jsonrpc: '2.0', id: msg.id, result }) + } catch (err) { + send({ jsonrpc: '2.0', id: msg.id, error: { message: err.message } }) + } + } + } + + function send(msg) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(msg)) + } + } + + function pushEvent(name, data = {}) { + send({ type: 'event', event: name, data }) + } + + // Serialized via mutex because upstream ViperIDE uses browser dialogs + // and we can't modify those function signatures. + const dialogMutex = new Mutex() + async function withDialogOverrides(overrides, fn) { + const release = await dialogMutex.acquire() + const origPrompt = window.prompt + const origConfirm = window.confirm + if (overrides.prompt !== undefined) window.prompt = () => overrides.prompt + if (overrides.confirm !== undefined) window.confirm = () => overrides.confirm + try { + return await fn() + } finally { + window.prompt = origPrompt + window.confirm = origConfirm + release() + } + } + + function readTerminalBuffer() { + const term = api.getTerm() + if (!term) return '' + const buf = term.buffer.active + const lines = [] + for (let i = 0; i < buf.length; i++) { + const line = buf.getLine(i) + if (line) lines.push(line.translateToString(true)) + } + while (lines.length > 0 && lines[lines.length - 1].trim() === '') { + lines.pop() + } + return lines.join('\n') + } + + async function withRawMode(fn) { + const port = api.getPort() + if (!port) throw new Error('No device connected') + const raw = await api.MpRawMode.begin(port) + try { + return await fn(raw) + } finally { + try { await raw.end() } catch (_e) { /* device may have disconnected */ } + } + } + + async function dispatch(method, params) { + switch (method) { + case 'get_status': { + const port = api.getPort() + return { + connected: !!port, + deviceInfo: api.getDevInfo(), + currentFile: api.getEditorFn(), + isRunning: api.isRunning(), + hasEditor: !!api.getEditor(), + } + } + + case 'connect_device': { + const { type, url, password } = params + if (type === 'usb' || type === 'ble') { + return { + requiresUserAction: true, + message: `${type.toUpperCase()} requires user interaction. Click the ${type === 'usb' ? 'USB' : 'Bluetooth'} button in the browser.`, + } + } + if (type === 'ws' && !url) throw new Error('URL is required for WebSocket connections') + const port = api.getPort() + if (port) await api.disconnectDevice() + if (url && type === 'ws') window.webrepl_url = url + if (type === 'vm') window.webrepl_url = 'vm://default' + const overrides = { confirm: true } + if (password) overrides.prompt = password + // VM is connected via the 'ws' path with a vm:// webrepl_url. + // Fire and forget - connectDevice includes raw mode handshake which + // can take 20+ seconds, exceeding Claude Desktop's API timeout. + const connType = type === 'vm' ? 'ws' : type + withDialogOverrides(overrides, () => api.connectDevice(connType)) + .catch(err => pushEvent('connect_error', { error: err.message })) + return { ok: true, message: 'Connection initiated. Use get_status to check when the device is ready.' } + } + + case 'disconnect_device': { + await api.disconnectDevice() + return { ok: true } + } + + case 'list_files': { + return withRawMode(async (raw) => { + const tree = await raw.walkFs() + const stats = await raw.getFsStats().catch(() => [null, null, null]) + const files = [] + function walk(nodes) { + for (const n of nodes) { + if ('content' in n) { + files.push({ path: n.path, type: 'dir' }) + walk(n.content) + } else { + files.push({ path: n.path, type: 'file', size: n.size }) + } + } + } + walk(tree) + return { files, fsUsed: stats[0], fsFree: stats[1], fsSize: stats[2] } + }) + } + + case 'read_file': { + const { path } = params + return withRawMode(async (raw) => { + const content = await raw.readFile(path) + const text = utf8Decoder.decode(content) + return { path, content: text } + }) + } + + case 'write_file': { + const { path, content } = params + return withRawMode(async (raw) => { + const [dir] = splitPath(path) + if (dir) await raw.makePath(dir) + await raw.writeFile(path, content) + return { ok: true, path } + }) + } + + case 'delete_file': { + const { path } = params + await withDialogOverrides({ confirm: true }, () => api.removeFile(path)) + return { ok: true } + } + + case 'delete_dir': { + const { path } = params + await withDialogOverrides({ confirm: true }, () => api.removeDir(path)) + return { ok: true } + } + + case 'mkdir': { + const { path } = params + return withRawMode(async (raw) => { + await raw.makePath(path) + return { ok: true } + }) + } + + case 'open_file': { + const { path } = params + await api.fileClick(path) + return { ok: true } + } + + case 'get_editor': { + const editor = api.getEditor() + if (!editor) return { content: null, filename: null } + return { + content: editor.state.doc.toString(), + filename: api.getEditorFn(), + } + } + + case 'set_editor': { + const { content } = params + const editor = api.getEditor() + if (!editor) throw new Error('No editor open') + editor.dispatch({ + changes: { from: 0, to: editor.state.doc.length, insert: content } + }) + return { ok: true } + } + + case 'save_file': { + if (!api.getPort()) throw new Error('No device connected') + if (!api.getEditor()) throw new Error('No file open in editor') + const { filename } = params + if (filename) { + await withDialogOverrides({ prompt: filename }, () => api.saveCurrentFile()) + } else { + await api.saveCurrentFile() + } + return { ok: true } + } + + case 'run_file': { + if (!api.getPort()) throw new Error('No device connected') + if (!api.getEditor()) throw new Error('No file open in editor') + api.runCurrentFile().catch(err => { + pushEvent('execution_error', { error: err.message }) + }) + return { ok: true, message: 'Execution started. Use read_terminal to monitor output, stop to interrupt.' } + } + + case 'stop': { + if (!api.getPort()) throw new Error('No device connected') + if (api.isRunning()) { + await api.getPort().write('\r\x03\x03') + return { ok: true } + } + return { ok: true, message: 'Nothing running' } + } + + case 'reboot': { + if (!api.getPort()) throw new Error('No device connected') + const { mode } = params + await api.reboot(mode || 'hard') + return { ok: true } + } + + case 'read_terminal': { + return { content: readTerminalBuffer() } + } + + case 'write_terminal': { + let { text } = params + const port = api.getPort() + if (!port) throw new Error('No device connected') + // Unescape literal \r \n \t \xNN sequences that arrive as text from JSON + text = text.replace(/\\r/g, '\r').replace(/\\n/g, '\n') + .replace(/\\t/g, '\t').replace(/\\x([0-9a-fA-F]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16))) + // Auto-append \r\n if the command doesn't end with a newline + if (!text.endsWith('\n') && !text.endsWith('\r')) text += '\r\n' + await port.write(text) + return { ok: true } + } + + case 'clear_terminal': { + api.clearTerminal() + return { ok: true } + } + + case 'install_package': { + const { name, version } = params + await api.installPkg(name, { version: version || null }) + return { ok: true } + } + + case 'create_file': { + const { path, name } = params + await withDialogOverrides({ prompt: name }, () => api.createNewFile(path)) + return { ok: true } + } + + case 'close_file': { + const { path } = params + const tab = document.querySelector(`#editor-tabs .tab[data-fn="${path}"]`) + if (!tab) return { ok: true, message: 'File not open' } + // Bypass unsaved changes confirm, then click close + await withDialogOverrides({ confirm: true }, () => { + tab.querySelector('.menu-action').click() + }) + return { ok: true } + } + + default: + throw new Error(`Unknown method: ${method}`) + } + } + + document.addEventListener('deviceConnected', () => pushEvent('device_connected')) + document.addEventListener('fileRemoved', (e) => pushEvent('file_removed', e.detail)) + document.addEventListener('dirRemoved', (e) => pushEvent('dir_removed', e.detail)) + document.addEventListener('fileRenamed', (e) => pushEvent('file_renamed', e.detail)) + document.addEventListener('fileSaved', (e) => pushEvent('file_saved', e.detail)) + document.addEventListener('editorLoaded', (e) => pushEvent('editor_loaded', { fn: e.detail.fn })) + + connect() +} diff --git a/src/rawmode.js b/src/rawmode.js index 5fec9ce..8790115 100644 --- a/src/rawmode.js +++ b/src/rawmode.js @@ -30,7 +30,7 @@ export class MpRawMode { while (timeout <= 0 || (Date.now() < endTime)) { await this.port.write('\x03') // Ctrl-C: interrupt any running program try { - let banner = await this.port.readUntil('>>> ', 500) + let banner = await this.port.readUntil('>>> ', 2000) if (this.port.prevRecvCbk && banner != '\r\n>>> ') { this.port.prevRecvCbk(banner) } diff --git a/src/transports.js b/src/transports.js index 7a8da4e..2bce45f 100644 --- a/src/transports.js +++ b/src/transports.js @@ -450,7 +450,7 @@ export class WebSocketREPL extends Transport { const release = await this.startTransaction() try { try { - await this.readUntil('Password:', 1000) + await this.readUntil('Password:', 5000) } catch (_err) { return } From 5c88c87d31588307ba25bd290f8e1858c1b7e0be Mon Sep 17 00:00:00 2001 From: Andrew Leech Date: Fri, 24 Apr 2026 16:47:25 +1000 Subject: [PATCH 2/2] MCP: fix stale device info, steer USB path, silence retry toasts - control_client: track connecting/connected state locally and snapshot deviceInfo on deviceConnected event. Fixes get_status reporting stale devInfo from a prior session because app.js never clears its module local on disconnect. - mcp/index.js: drop 'usb' from connect_device enum and document connect_serial as the USB path, so Claude does not route USB connect requests to the browser click flow. - mcp/index.js: expose the bundled ideUrl in get_status and add an open_ide tool so the model stops suggesting viper-ide.org when the browser tab is closed. - rawmode: interruptProgram per retry failures now log to console.debug instead of popping a red toast; only the final 20s timeout surfaces as a user visible error. --- mcp/src/index.js | 31 ++++++++++++++++++++++++------- src/control_client.js | 32 ++++++++++++++++++++++++++++---- src/rawmode.js | 5 +++-- 3 files changed, 55 insertions(+), 13 deletions(-) diff --git a/mcp/src/index.js b/mcp/src/index.js index edcefaa..974b039 100644 --- a/mcp/src/index.js +++ b/mcp/src/index.js @@ -77,7 +77,7 @@ const mcp = new McpServer({ // get_status is handled separately so it works without the browser mcp.tool( 'viperIDE_get_status', - 'Get ViperIDE connection status, device info, and editor state', + 'Get ViperIDE connection status, device info, and editor state. ViperIDE is served locally from this MCP server at ideUrl; it is NOT at viper-ide.org. If browserConnected is false the tab can be (re)launched with viperIDE_open_ide or by directing the user to ideUrl. The connected/connecting fields reflect the actual device connection state, not the browser state.', {}, async () => { await ensureInit() @@ -86,24 +86,41 @@ mcp.tool( browserConnected: false, connected: false, deviceInfo: null, - message: 'ViperIDE browser tab is not open yet. Call any other tool to launch it.', + ideUrl, + message: 'ViperIDE browser tab is not open. Call viperIDE_open_ide to launch it, or open ideUrl in a browser. Do NOT direct the user to viper-ide.org - this MCP serves its own bundled copy locally.', }) } try { const result = await bridge.call('get_status') - return textResult({ browserConnected: true, ...result }) + return textResult({ browserConnected: true, ideUrl, ...result }) } catch (err) { return errorResult(err.message) } } ) +mcp.tool( + 'viperIDE_open_ide', + 'Open the bundled ViperIDE in the user\'s default browser. Use this if get_status reports browserConnected: false, or if the user says they can\'t see ViperIDE. The URL is served by this MCP server on localhost; never direct the user to viper-ide.org.', + {}, + async () => { + await ensureBrowser() + return textResult({ + ideUrl, + browserConnected: bridge.isConnected, + message: bridge.isConnected + ? 'ViperIDE browser tab is already connected.' + : 'Launching ViperIDE in the default browser. The tab should appear momentarily; poll get_status until browserConnected is true.', + }) + } +) + const tools = [ { name: 'viperIDE_connect_device', - description: 'Connect to a MicroPython device. Use "ws" with a URL for WebSocket, "vm" for the built-in virtual device. USB and Bluetooth require the user to click the connect button in the browser. Non-blocking; the device handshake takes 10-20 seconds. Poll get_status every 5 seconds until connected is true; do not assume failure before 30 seconds.', + description: 'Connect to a MicroPython device over WebSocket (WebREPL), Bluetooth LE, or the built-in virtual device. For USB serial devices do NOT use this tool; use viperIDE_list_serial_ports + viperIDE_connect_serial instead (they go through the local serial bridge and work without browser interaction). Use type "ws" with a URL for WebREPL, "ble" for Bluetooth LE (requires user to pick the device in the browser), "vm" for the virtual device. Non-blocking; the device handshake takes 10-20 seconds. Poll viperIDE_get_status every 5 seconds until connected is true; do not assume failure before 30 seconds.', schema: { - type: z.enum(['ws', 'ble', 'usb', 'vm']).describe('Connection type'), + type: z.enum(['ws', 'ble', 'vm']).describe('Connection type: ws (WebREPL), ble (Bluetooth LE), vm (virtual device). For USB, use viperIDE_connect_serial instead.'), url: z.string().optional().describe('WebSocket URL, e.g. ws://192.168.1.1:8266 (required for ws type)'), password: z.string().optional().describe('WebREPL password (for ws type)'), }, @@ -255,7 +272,7 @@ for (const { name, description, schema, method, transform } of tools) { mcp.tool( 'viperIDE_list_serial_ports', - 'List available USB serial ports. Returns likelyPorts (known MicroPython-capable boards by VID: Raspberry Pi, Adafruit, Espressif, FTDI, etc.) and allPorts. On Windows, ports appear as COMx; on Linux as /dev/ttyACMx or /dev/serial/by-id/...', + 'List available USB serial ports on the host. Use this as the FIRST step when the user asks to connect to a USB MicroPython device. Returns likelyPorts (MicroPython-capable boards identified by USB VID: Raspberry Pi/Pico, Adafruit, Espressif, WCH, Silicon Labs, FTDI, etc.) and allPorts. On Windows, ports appear as COMx; on Linux as /dev/ttyACMx or /dev/serial/by-id/...', {}, async () => { await ensureInit() @@ -270,7 +287,7 @@ mcp.tool( mcp.tool( 'viperIDE_connect_serial', - 'Connect to a MicroPython device via USB serial through the local WebSocket bridge. Use list_serial_ports first to find the port path. On Windows use COMx format, on Linux use /dev/ttyACMx or /dev/serial/by-id/... paths. Non-blocking; the device handshake takes 10-20 seconds. Poll get_status every 5 seconds until connected is true; do not assume failure before 30 seconds.', + 'Connect to a USB MicroPython device. This is the PREFERRED way to connect to any USB/serial MicroPython board (do NOT use viperIDE_connect_device with type "usb"). Goes through the local serial bridge so no browser interaction is required. Call viperIDE_list_serial_ports first to find the port path; if exactly one likelyPort is returned, use it without prompting. On Windows use COMx format, on Linux use /dev/ttyACMx or /dev/serial/by-id/... paths. Non-blocking; the device handshake takes 10-20 seconds. Poll viperIDE_get_status every 5 seconds until connected is true; do not assume failure before 30 seconds.', { port_path: z.string().describe('Serial port path, e.g. COM4 or /dev/serial/by-id/usb-MicroPython_Board_...'), }, diff --git a/src/control_client.js b/src/control_client.js index 7c5462f..d4be616 100644 --- a/src/control_client.js +++ b/src/control_client.js @@ -23,6 +23,13 @@ export function initControlClient(api) { let ws = null let reconnectDelay = 2000 + // Tracks the MCP-visible connection state. app.js does not clear its + // module-local devInfo on disconnect, so we can't trust api.getDevInfo() + // to reflect the active port. Instead we snapshot devInfo here when the + // deviceConnected event fires, and drop it when the port goes away. + let connecting = false + let lastConnectedDevInfo = null + function connect() { ws = new WebSocket(wsUrl) @@ -119,9 +126,15 @@ export function initControlClient(api) { switch (method) { case 'get_status': { const port = api.getPort() + if (!port) { + lastConnectedDevInfo = null + connecting = false + } + const isConnected = !!port && lastConnectedDevInfo !== null return { - connected: !!port, - deviceInfo: api.getDevInfo(), + connected: isConnected, + connecting: !!port && connecting && !isConnected, + deviceInfo: isConnected ? lastConnectedDevInfo : null, currentFile: api.getEditorFn(), isRunning: api.isRunning(), hasEditor: !!api.getEditor(), @@ -147,13 +160,20 @@ export function initControlClient(api) { // Fire and forget - connectDevice includes raw mode handshake which // can take 20+ seconds, exceeding Claude Desktop's API timeout. const connType = type === 'vm' ? 'ws' : type + connecting = true + lastConnectedDevInfo = null withDialogOverrides(overrides, () => api.connectDevice(connType)) - .catch(err => pushEvent('connect_error', { error: err.message })) + .catch(err => { + connecting = false + pushEvent('connect_error', { error: err.message }) + }) return { ok: true, message: 'Connection initiated. Use get_status to check when the device is ready.' } } case 'disconnect_device': { await api.disconnectDevice() + connecting = false + lastConnectedDevInfo = null return { ok: true } } @@ -328,7 +348,11 @@ export function initControlClient(api) { } } - document.addEventListener('deviceConnected', () => pushEvent('device_connected')) + document.addEventListener('deviceConnected', () => { + lastConnectedDevInfo = api.getDevInfo() || null + connecting = false + pushEvent('device_connected', lastConnectedDevInfo || {}) + }) document.addEventListener('fileRemoved', (e) => pushEvent('file_removed', e.detail)) document.addEventListener('dirRemoved', (e) => pushEvent('dir_removed', e.detail)) document.addEventListener('fileRenamed', (e) => pushEvent('file_renamed', e.detail)) diff --git a/src/rawmode.js b/src/rawmode.js index 8790115..fef058e 100644 --- a/src/rawmode.js +++ b/src/rawmode.js @@ -6,7 +6,6 @@ * This includes no assurances about being fit for any specific purpose. */ -import { report } from "./utils" export class MpRawMode { constructor(port) { @@ -37,7 +36,9 @@ export class MpRawMode { await this.port.flushInput() return } catch (err) { - report("Error", err) + // Per-retry readUntil timeouts are expected while the device is busy. + // Only the final outer-timeout throw below is a real error surfaced to the user. + console.debug('interruptProgram retry:', err.message) } } throw new Error('Board is not responding')