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..974b039 --- /dev/null +++ b/mcp/src/index.js @@ -0,0 +1,322 @@ +#!/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. 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() + if (!bridge.isConnected) { + return textResult({ + browserConnected: false, + connected: false, + deviceInfo: null, + 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, 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 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', '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)'), + }, + 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 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() + try { + const result = await serialBridge.listPorts() + return textResult(result) + } catch (err) { + return errorResult(err.message) + } + } +) + +mcp.tool( + 'viperIDE_connect_serial', + '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_...'), + }, + 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..d4be616 --- /dev/null +++ b/src/control_client.js @@ -0,0 +1,363 @@ +/* + * 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 + + // 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) + + 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() + if (!port) { + lastConnectedDevInfo = null + connecting = false + } + const isConnected = !!port && lastConnectedDevInfo !== null + return { + connected: isConnected, + connecting: !!port && connecting && !isConnected, + deviceInfo: isConnected ? lastConnectedDevInfo : null, + 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 + connecting = true + lastConnectedDevInfo = null + withDialogOverrides(overrides, () => api.connectDevice(connType)) + .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 } + } + + 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', () => { + 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)) + 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..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) { @@ -30,14 +29,16 @@ 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) } 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') 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 }