#
tokens: 9047/50000 10/10 files
lines: on (toggle) GitHub
raw markdown copy reset
# Directory Structure

```
├── .gitignore
├── frontend
│   └── src
│       ├── index.html
│       ├── main.js
│       └── style.css
├── index.ts
├── package-lock.json
├── package.json
├── README.md
├── terminal.html
├── tsconfig.json
└── vite.config.js
```

# Files

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
 1 | # Dependencies
 2 | /node_modules
 3 | 
 4 | # Production build files
 5 | /dist
 6 | 
 7 | # Logs
 8 | npm-debug.log*
 9 | yarn-debug.log*
10 | yarn-error.log*
11 | 
12 | # Local env files
13 | .env*
14 | !.env.example
15 | 
16 | # Editor directories and files
17 | .idea
18 | .vscode
19 | *.suo
20 | *.ntvs*
21 | *.njsproj
22 | *.sln
23 | *.sw?
24 | 
25 | # OS files
26 | .DS_Store
27 | Thumbs.db
28 | dist
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
  1 | # MCP Tunnel
  2 | 
  3 | A simple MCP (Model Context Protocol) server that allows accessing the command line of a VM machine. When started, it also tunnels the host to the web so it can be accessed via MCP.
  4 | 
  5 | ## Features
  6 | 
  7 | - Execute shell commands on a VM through MCP
  8 | - Web-based terminal interface for VM interaction
  9 | - Automatic tunneling to make the VM accessible from anywhere
 10 | - WebSocket-based real-time communication
 11 | 
 12 | ## Prerequisites
 13 | 
 14 | - Node.js (v18 or newer)
 15 | 
 16 | ## Installation and Usage
 17 | 
 18 | ### Running with npx (no installation)
 19 | 
 20 | ```bash
 21 | npx mcp-cli
 22 | ```
 23 | 
 24 | ### Global Installation
 25 | 
 26 | ```bash
 27 | npm install -g mcp-cli
 28 | mcp-cli
 29 | ```
 30 | 
 31 | ### Local Development
 32 | 
 33 | ```bash
 34 | # Clone repository
 35 | git clone [repository-url]
 36 | cd mcp-cli
 37 | 
 38 | # Install dependencies
 39 | npm install
 40 | ```
 41 | 
 42 | ## Development
 43 | 
 44 | Run the development server with hot-reloading for both backend and frontend:
 45 | 
 46 | ```bash
 47 | npm run dev
 48 | ```
 49 | 
 50 | ## Building
 51 | 
 52 | Build both the frontend and backend for production:
 53 | 
 54 | ```bash
 55 | npm run build-all
 56 | ```
 57 | 
 58 | ## Usage
 59 | 
 60 | 1. Start the MCP server:
 61 | 
 62 | ```bash
 63 | # Start with automatic tunneling
 64 | npm start
 65 | 
 66 | # Start without automatic tunneling
 67 | npm start -- --no-tunnel
 68 | ```
 69 | 
 70 | This will build the project and start the server. By default, a tunnel will be created automatically. Use the `--no-tunnel` flag to disable automatic tunneling.
 71 | 
 72 | 2. The server will start and provide output on stderr (to avoid interfering with MCP communication on stdout)
 73 | 
 74 | 3. Use MCP to interact with the server using the following tools:
 75 | 
 76 | ### Available MCP Tools
 77 | 
 78 | - `execute_command`: Run a shell command on the VM
 79 |   - Parameters: `{ "command": "your shell command" }`
 80 | - `start_tunnel`: Create a web tunnel to access the VM interface
 81 |   - Parameters: `{ "port": 8080, "subdomain": "optional-subdomain" }`
 82 | 
 83 | ## Web Interface
 84 | 
 85 | After starting the tunnel, you can access the web-based terminal interface at the URL provided by the tunnel. This interface allows you to:
 86 | 
 87 | - Execute commands directly in the VM
 88 | - See command outputs in real-time
 89 | - Interact with the VM from any device with web access
 90 | 
 91 | ## Environment Variables
 92 | 
 93 | Create a `.env` file to configure the server:
 94 | 
 95 | ```
 96 | # Server configuration
 97 | PORT=8080
 98 | 
 99 | # Localtunnel configuration
100 | LOCALTUNNEL_SUBDOMAIN=your-preferred-subdomain
101 | ```
102 | 
103 | ## Security Considerations
104 | 
105 | This tool provides direct access to your VM's command line. Consider these security practices:
106 | 
107 | - Use strong authentication mechanisms before exposing the tunnel
108 | - Limit the commands that can be executed through proper validation
109 | - Consider running in a restricted environment
110 | - Do not expose sensitive information through the tunnel
111 | 
```

--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------

```javascript
 1 | import { defineConfig } from 'vite';
 2 | 
 3 | export default defineConfig({
 4 |   root: './frontend/src',
 5 |   publicDir: '../public',
 6 |   server: {
 7 |     port: 3000,
 8 |   },
 9 |   build: {
10 |     outDir: '../../dist',
11 |     emptyOutDir: true,
12 |     sourcemap: true,
13 |   },
14 | });
```

--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------

```json
 1 | {
 2 |   "compilerOptions": {
 3 |     "target": "ES2020",
 4 |     "module": "ESNext",
 5 |     "moduleResolution": "node",
 6 |     "esModuleInterop": true,
 7 |     "strict": true,
 8 |     "skipLibCheck": true,
 9 |     "outDir": "dist"
10 |   },
11 |   "include": ["index.ts"],
12 |   "exclude": ["node_modules", "dist", "frontend"]
13 | }
14 | 
```

--------------------------------------------------------------------------------
/frontend/src/index.html:
--------------------------------------------------------------------------------

```html
 1 | <!DOCTYPE html>
 2 | <html lang="en">
 3 |   <head>
 4 |     <meta charset="UTF-8" />
 5 |     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 6 |     <title>VM Terminal</title>
 7 |     <link rel="stylesheet" href="./style.css">
 8 |   </head>
 9 |   <body>
10 |     <div class="header">
11 |       <div>VM Terminal</div>
12 |       <button id="clear-btn">Clear</button>
13 |     </div>
14 |     <div id="terminal-container"></div>
15 |     <script type="module" src="./main.js"></script>
16 |   </body>
17 | </html>
```

--------------------------------------------------------------------------------
/frontend/src/style.css:
--------------------------------------------------------------------------------

```css
 1 | body {
 2 |   margin: 0;
 3 |   padding: 0;
 4 |   height: 100vh;
 5 |   display: flex;
 6 |   flex-direction: column;
 7 |   background-color: #1e1e1e;
 8 | }
 9 | #terminal-container {
10 |   flex: 1;
11 |   padding: 10px;
12 |   height: calc(100vh - 20px);
13 | }
14 | #terminal-container .terminal {
15 |   height: 100%;
16 | }
17 | .header {
18 |   background-color: #282828;
19 |   color: #f0f0f0;
20 |   padding: 5px 10px;
21 |   font-family: sans-serif;
22 |   font-size: 14px;
23 |   display: flex;
24 |   justify-content: space-between;
25 |   align-items: center;
26 | }
27 | .header button {
28 |   background-color: #3a3a3a;
29 |   color: #f0f0f0;
30 |   border: none;
31 |   padding: 5px 10px;
32 |   border-radius: 3px;
33 |   cursor: pointer;
34 | }
35 | .header button:hover {
36 |   background-color: #4a4a4a;
37 | }
```

--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------

```json
 1 | {
 2 |   "name": "mcp-cli",
 3 |   "version": "1.0.0",
 4 |   "description": "MCP server for accessing VM command line with web tunneling",
 5 |   "main": "dist/index.js",
 6 |   "type": "module",
 7 |   "bin": {
 8 |     "mcp-tunnel": "./dist/index.js"
 9 |   },
10 |   "scripts": {
11 |     "start": "npm run build-all && node dist/index.js",
12 |     "build-all": "npm run build-frontend && npm run build-server",
13 |     "build-frontend": "vite build",
14 |     "build-server": "tsc",
15 |     "dev": "concurrently \"ts-node index.ts\" \"vite --host\"",
16 |     "preview": "vite preview",
17 |     "prepare": "npm run build-all"
18 |   },
19 |   "dependencies": {
20 |     "@modelcontextprotocol/sdk": "^1.7.0",
21 |     "@xterm/addon-fit": "^0.10.0",
22 |     "@xterm/addon-web-links": "^0.11.0",
23 |     "@xterm/xterm": "^5.5.0",
24 |     "dotenv": "^16.4.1",
25 |     "express": "^5.0.1",
26 |     "localtunnel": "^2.0.2",
27 |     "typescript": "^5.8.2",
28 |     "ws": "^8.16.0",
29 |     "zod": "^3.22.4",
30 |     "zod-to-json-schema": "^3.22.3"
31 |   },
32 |   "engines": {
33 |     "node": ">=18.0.0"
34 |   },
35 |   "devDependencies": {
36 |     "@types/express": "^5.0.0",
37 |     "@types/localtunnel": "^2.0.4",
38 |     "@types/node": "^22.13.10",
39 |     "@types/ws": "^8.18.0",
40 |     "@vitejs/plugin-react": "^4.3.4",
41 |     "concurrently": "^9.1.2",
42 |     "ts-node": "^10.9.2",
43 |     "vite": "^6.2.2"
44 |   },
45 |   "publishConfig": {
46 |     "access": "public"
47 |   },
48 |   "keywords": [
49 |     "mcp",
50 |     "tunnel",
51 |     "terminal",
52 |     "vm",
53 |     "cli"
54 |   ]
55 | }
56 | 
```

--------------------------------------------------------------------------------
/frontend/src/main.js:
--------------------------------------------------------------------------------

```javascript
  1 | import { Terminal } from '@xterm/xterm';
  2 | import { FitAddon } from '@xterm/addon-fit';
  3 | import { WebLinksAddon } from '@xterm/addon-web-links';
  4 | import '@xterm/xterm/css/xterm.css';
  5 | 
  6 | const term = new Terminal({
  7 |   cursorBlink: true,
  8 |   theme: {
  9 |     background: "#1e1e1e",
 10 |     foreground: "#f0f0f0",
 11 |     cursor: "#f0f0f0",
 12 |     selectionBackground: "#565656"
 13 |   },
 14 |   fontFamily: 'Menlo, Monaco, "Courier New", monospace',
 15 |   fontSize: 14,
 16 |   lineHeight: 1.2,
 17 |   scrollback: 5000,
 18 |   cursorStyle: "block"
 19 | });
 20 | 
 21 | // Add addons
 22 | const fitAddon = new FitAddon();
 23 | term.loadAddon(fitAddon);
 24 | term.loadAddon(new WebLinksAddon());
 25 | 
 26 | term.open(document.getElementById("terminal-container"));
 27 | fitAddon.fit();
 28 | term.focus();
 29 | 
 30 | // Handle window resize
 31 | window.addEventListener("resize", () => {
 32 |   fitAddon.fit();
 33 | });
 34 | 
 35 | // Clear button functionality
 36 | document.getElementById("clear-btn").addEventListener("click", () => {
 37 |   term.clear();
 38 | });
 39 | 
 40 | let ws;
 41 | let commandBuffer = "";
 42 | let commandHistory = [];
 43 | let historyPosition = -1;
 44 | 
 45 | function connectWebSocket() {
 46 |   const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
 47 |   ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
 48 | 
 49 |   ws.onopen = () => {
 50 |     term.writeln("\r\nConnected to VM terminal");
 51 |     term.write("\r\n$ ");
 52 |   };
 53 | 
 54 |   ws.onmessage = (event) => {
 55 |     const data = JSON.parse(event.data);
 56 |     if (data.type === "output") {
 57 |       term.write(data.content);
 58 |       if (!data.content.endsWith("\n")) {
 59 |         term.write("\r\n");
 60 |       }
 61 |       term.write("$ ");
 62 |     }
 63 |   };
 64 | 
 65 |   ws.onclose = () => {
 66 |     term.writeln("\r\nConnection lost. Reconnecting...");
 67 |     setTimeout(connectWebSocket, 2000);
 68 |   };
 69 | 
 70 |   ws.onerror = (error) => {
 71 |     console.error("WebSocket error:", error);
 72 |     term.writeln("\r\nConnection error. Please try again.");
 73 |   };
 74 | }
 75 | 
 76 | function clearCurrentLine() {
 77 |   const currentLine = commandBuffer;
 78 |   for (let i = 0; i < currentLine.length; i++) {
 79 |     term.write("\b \b");
 80 |   }
 81 |   return currentLine;
 82 | }
 83 | 
 84 | term.onKey(({ key, domEvent }) => {
 85 |   const printable =
 86 |     !domEvent.altKey && !domEvent.ctrlKey && !domEvent.metaKey;
 87 | 
 88 |   if (domEvent.keyCode === 13) {
 89 |     // Enter key
 90 |     term.write("\r\n");
 91 | 
 92 |     if (commandBuffer.trim() !== "") {
 93 |       if (ws && ws.readyState === WebSocket.OPEN) {
 94 |         ws.send(
 95 |           JSON.stringify({
 96 |             type: "command",
 97 |             command: commandBuffer
 98 |           })
 99 |         );
100 | 
101 |         // Add to history if not duplicate
102 |         if (
103 |           commandHistory.length === 0 ||
104 |           commandHistory[commandHistory.length - 1] !== commandBuffer
105 |         ) {
106 |           commandHistory.push(commandBuffer);
107 |         }
108 |         historyPosition = -1;
109 |       } else {
110 |         term.writeln("Not connected to the server.");
111 |         term.write("$ ");
112 |       }
113 |     } else {
114 |       term.write("$ ");
115 |     }
116 | 
117 |     commandBuffer = "";
118 |   } else if (domEvent.keyCode === 8) {
119 |     // Backspace
120 |     if (commandBuffer.length > 0) {
121 |       commandBuffer = commandBuffer.slice(0, -1);
122 |       term.write("\b \b");
123 |     }
124 |   } else if (domEvent.keyCode === 38) {
125 |     // Up arrow - History previous
126 |     if (commandHistory.length > 0) {
127 |       if (historyPosition === -1) {
128 |         historyPosition = commandHistory.length - 1;
129 |       } else if (historyPosition > 0) {
130 |         historyPosition--;
131 |       }
132 | 
133 |       clearCurrentLine();
134 |       commandBuffer = commandHistory[historyPosition];
135 |       term.write(commandBuffer);
136 |     }
137 |   } else if (domEvent.keyCode === 40) {
138 |     // Down arrow - History next
139 |     if (historyPosition !== -1) {
140 |       if (historyPosition < commandHistory.length - 1) {
141 |         historyPosition++;
142 |         clearCurrentLine();
143 |         commandBuffer = commandHistory[historyPosition];
144 |         term.write(commandBuffer);
145 |       } else {
146 |         historyPosition = -1;
147 |         clearCurrentLine();
148 |         commandBuffer = "";
149 |       }
150 |     }
151 |   } else if (domEvent.ctrlKey && key.toLowerCase() === "c") {
152 |     // Ctrl+C
153 |     term.write("^C\r\n$ ");
154 |     commandBuffer = "";
155 |   } else if (domEvent.ctrlKey && key.toLowerCase() === "l") {
156 |     // Ctrl+L (clear)
157 |     term.clear();
158 |     term.write("$ " + commandBuffer);
159 |   } else if (printable) {
160 |     commandBuffer += key;
161 |     term.write(key);
162 |   }
163 | });
164 | 
165 | window.addEventListener("load", connectWebSocket);
```

--------------------------------------------------------------------------------
/terminal.html:
--------------------------------------------------------------------------------

```html
  1 | <!DOCTYPE html>
  2 | <html lang="en">
  3 |   <head>
  4 |     <meta charset="UTF-8" />
  5 |     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  6 |     <title>VM Terminal</title>
  7 |     <script src="https://cdn.jsdelivr.net/npm/@xterm/[email protected]/lib/xterm.min.js"></script>
  8 |     <link
  9 |       href="https://cdn.jsdelivr.net/npm/@xterm/[email protected]/css/xterm.min.css"
 10 |       rel="stylesheet"
 11 |     />
 12 |     <script src="https://cdn.jsdelivr.net/npm/@xterm/[email protected]/lib/addon-fit.min.js"></script>
 13 |     <script src="https://cdn.jsdelivr.net/npm/@xterm/[email protected]/lib/addon-web-links.min.js"></script>
 14 |     <style>
 15 |       body {
 16 |         margin: 0;
 17 |         padding: 0;
 18 |         height: 100vh;
 19 |         display: flex;
 20 |         flex-direction: column;
 21 |         background-color: #1e1e1e;
 22 |       }
 23 |       #terminal-container {
 24 |         flex: 1;
 25 |         padding: 10px;
 26 |         height: calc(100vh - 20px);
 27 |       }
 28 |       #terminal-container .terminal {
 29 |         height: 100%;
 30 |       }
 31 |       .header {
 32 |         background-color: #282828;
 33 |         color: #f0f0f0;
 34 |         padding: 5px 10px;
 35 |         font-family: sans-serif;
 36 |         font-size: 14px;
 37 |         display: flex;
 38 |         justify-content: space-between;
 39 |         align-items: center;
 40 |       }
 41 |       .header button {
 42 |         background-color: #3a3a3a;
 43 |         color: #f0f0f0;
 44 |         border: none;
 45 |         padding: 5px 10px;
 46 |         border-radius: 3px;
 47 |         cursor: pointer;
 48 |       }
 49 |       .header button:hover {
 50 |         background-color: #4a4a4a;
 51 |       }
 52 |     </style>
 53 |   </head>
 54 |   <body>
 55 |     <div class="header">
 56 |       <div>VM Terminal</div>
 57 |       <button id="clear-btn">Clear</button>
 58 |     </div>
 59 |     <div id="terminal-container"></div>
 60 | 
 61 |     <script type="module">
 62 |       const term = new Terminal({
 63 |         cursorBlink: true,
 64 |         theme: {
 65 |           background: "#1e1e1e",
 66 |           foreground: "#f0f0f0",
 67 |           cursor: "#f0f0f0",
 68 |           selectionBackground: "#565656"
 69 |         },
 70 |         fontFamily: 'Menlo, Monaco, "Courier New", monospace',
 71 |         fontSize: 14,
 72 |         lineHeight: 1.2,
 73 |         scrollback: 5000,
 74 |         cursorStyle: "block"
 75 |       });
 76 | 
 77 |       // Add addons
 78 |       const fitAddon = new FitAddon();
 79 |       term.loadAddon(fitAddon);
 80 |       term.loadAddon(new WebLinksAddon());
 81 | 
 82 |       term.open(document.getElementById("terminal-container"));
 83 |       fitAddon.fit();
 84 |       term.focus();
 85 | 
 86 |       // Handle window resize
 87 |       window.addEventListener("resize", () => {
 88 |         fitAddon.fit();
 89 |       });
 90 | 
 91 |       // Clear button functionality
 92 |       document.getElementById("clear-btn").addEventListener("click", () => {
 93 |         term.clear();
 94 |       });
 95 | 
 96 |       let ws;
 97 |       let commandBuffer = "";
 98 |       let commandHistory = [];
 99 |       let historyPosition = -1;
100 | 
101 |       function connectWebSocket() {
102 |         ws = new WebSocket("ws://" + window.location.host);
103 | 
104 |         ws.onopen = () => {
105 |           term.writeln("\r\nConnected to VM terminal");
106 |           term.write("\r\n$ ");
107 |         };
108 | 
109 |         ws.onmessage = (event) => {
110 |           const data = JSON.parse(event.data);
111 |           if (data.type === "output") {
112 |             term.write(data.content);
113 |             if (!data.content.endsWith("\n")) {
114 |               term.write("\r\n");
115 |             }
116 |             term.write("$ ");
117 |           }
118 |         };
119 | 
120 |         ws.onclose = () => {
121 |           term.writeln("\r\nConnection lost. Reconnecting...");
122 |           setTimeout(connectWebSocket, 2000);
123 |         };
124 | 
125 |         ws.onerror = (error) => {
126 |           console.error("WebSocket error:", error);
127 |           term.writeln("\r\nConnection error. Please try again.");
128 |         };
129 |       }
130 | 
131 |       function clearCurrentLine() {
132 |         const currentLine = commandBuffer;
133 |         for (let i = 0; i < currentLine.length; i++) {
134 |           term.write("\b \b");
135 |         }
136 |         return currentLine;
137 |       }
138 | 
139 |       term.onKey(({ key, domEvent }) => {
140 |         const printable =
141 |           !domEvent.altKey && !domEvent.ctrlKey && !domEvent.metaKey;
142 | 
143 |         if (domEvent.keyCode === 13) {
144 |           // Enter key
145 |           term.write("\r\n");
146 | 
147 |           if (commandBuffer.trim() !== "") {
148 |             if (ws && ws.readyState === WebSocket.OPEN) {
149 |               ws.send(
150 |                 JSON.stringify({
151 |                   type: "command",
152 |                   command: commandBuffer
153 |                 })
154 |               );
155 | 
156 |               // Add to history if not duplicate
157 |               if (
158 |                 commandHistory.length === 0 ||
159 |                 commandHistory[commandHistory.length - 1] !== commandBuffer
160 |               ) {
161 |                 commandHistory.push(commandBuffer);
162 |               }
163 |               historyPosition = -1;
164 |             } else {
165 |               term.writeln("Not connected to the server.");
166 |               term.write("$ ");
167 |             }
168 |           } else {
169 |             term.write("$ ");
170 |           }
171 | 
172 |           commandBuffer = "";
173 |         } else if (domEvent.keyCode === 8) {
174 |           // Backspace
175 |           if (commandBuffer.length > 0) {
176 |             commandBuffer = commandBuffer.slice(0, -1);
177 |             term.write("\b \b");
178 |           }
179 |         } else if (domEvent.keyCode === 38) {
180 |           // Up arrow - History previous
181 |           if (commandHistory.length > 0) {
182 |             if (historyPosition === -1) {
183 |               historyPosition = commandHistory.length - 1;
184 |             } else if (historyPosition > 0) {
185 |               historyPosition--;
186 |             }
187 | 
188 |             clearCurrentLine();
189 |             commandBuffer = commandHistory[historyPosition];
190 |             term.write(commandBuffer);
191 |           }
192 |         } else if (domEvent.keyCode === 40) {
193 |           // Down arrow - History next
194 |           if (historyPosition !== -1) {
195 |             if (historyPosition < commandHistory.length - 1) {
196 |               historyPosition++;
197 |               clearCurrentLine();
198 |               commandBuffer = commandHistory[historyPosition];
199 |               term.write(commandBuffer);
200 |             } else {
201 |               historyPosition = -1;
202 |               clearCurrentLine();
203 |               commandBuffer = "";
204 |             }
205 |           }
206 |         } else if (domEvent.ctrlKey && key.toLowerCase() === "c") {
207 |           // Ctrl+C
208 |           term.write("^C\r\n$ ");
209 |           commandBuffer = "";
210 |         } else if (domEvent.ctrlKey && key.toLowerCase() === "l") {
211 |           // Ctrl+L (clear)
212 |           term.clear();
213 |           term.write("$ " + commandBuffer);
214 |         } else if (printable) {
215 |           commandBuffer += key;
216 |           term.write(key);
217 |         }
218 |       });
219 | 
220 |       window.addEventListener("load", connectWebSocket);
221 |     </script>
222 |   </body>
223 | </html>
224 | 
```

--------------------------------------------------------------------------------
/index.ts:
--------------------------------------------------------------------------------

```typescript
  1 | #!/usr/bin/env node
  2 | import { Server } from "@modelcontextprotocol/sdk/server/index.js";
  3 | import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
  4 | import {
  5 |   ListToolsRequestSchema,
  6 |   CallToolRequestSchema,
  7 |   ErrorCode,
  8 |   McpError
  9 | } from "@modelcontextprotocol/sdk/types.js";
 10 | import { z } from "zod";
 11 | import { zodToJsonSchema } from "zod-to-json-schema";
 12 | import { spawn } from "child_process";
 13 | import { createServer } from "http";
 14 | import { WebSocketServer } from "ws";
 15 | import { createReadStream, existsSync } from "fs";
 16 | import { join } from "path";
 17 | import { fileURLToPath } from "url";
 18 | import { dirname } from "path";
 19 | import dotenv from "dotenv";
 20 | import localtunnel from "localtunnel";
 21 | import express from "express";
 22 | 
 23 | dotenv.config();
 24 | 
 25 | // Schemas for our tools
 26 | const shellCommandSchema = z.object({
 27 |   command: z.string().describe("Shell command to execute on the VM")
 28 | });
 29 | 
 30 | const tunnelConfigSchema = z.object({
 31 |   port: z.number().default(8080).describe("Port to tunnel to the web"),
 32 |   subdomain: z.string().optional().describe("Optional subdomain for the tunnel")
 33 | });
 34 | 
 35 | class VmMcpServer {
 36 |   private server: Server;
 37 |   private webServer: any;
 38 |   private wss!: WebSocketServer;
 39 |   private tunnel: any;
 40 |   private tunnelUrl: string | undefined;
 41 |   private serverPort = 8080;
 42 |   private __dirname = dirname(fileURLToPath(import.meta.url));
 43 |   private noTunnel = process.argv.includes("--no-tunnel");
 44 |   private app: any;
 45 |   private transport: any;
 46 | 
 47 |   constructor() {
 48 |     this.server = new Server(
 49 |       {
 50 |         name: "vm-mcp-server",
 51 |         version: "0.1.0"
 52 |       },
 53 |       {
 54 |         capabilities: {
 55 |           resources: {},
 56 |           tools: {}
 57 |         }
 58 |       }
 59 |     );
 60 | 
 61 |     this.setupHandlers();
 62 |     this.setupErrorHandling();
 63 |     this.setupWebServer();
 64 |   }
 65 | 
 66 |   private setupWebServer() {
 67 |     this.app = express();
 68 |     const distPath = join(this.__dirname, "/");
 69 |     const devPath = join(this.__dirname, "frontend", "src");
 70 | 
 71 |     // Check if we're in production (using built files) or development
 72 |     const isProduction = existsSync(distPath);
 73 |     const staticPath = isProduction ? distPath : devPath;
 74 | 
 75 |     // Serve static files
 76 |     this.app.use(express.static(staticPath));
 77 | 
 78 |     // Fallback route for SPA
 79 |     this.app.get("/", (req: any, res: any) => {
 80 |       res.sendFile(join(staticPath, "index.html"));
 81 |     });
 82 | 
 83 |     // Create HTTP server from Express app
 84 |     this.webServer = createServer(this.app);
 85 | 
 86 |     // Create WebSocket server for real-time communication
 87 |     this.wss = new WebSocketServer({
 88 |       server: this.webServer,
 89 |       path: "/ws"
 90 |     });
 91 | 
 92 |     this.wss.on("connection", (ws) => {
 93 |       console.error("Client connected to WebSocket");
 94 | 
 95 |       ws.on("message", (message) => {
 96 |         try {
 97 |           const data = JSON.parse(message.toString());
 98 |           if (data.type === "command") {
 99 |             this.executeCommand(data.command, (output) => {
100 |               ws.send(JSON.stringify({ type: "output", content: output }));
101 |             });
102 |           }
103 |         } catch (error) {
104 |           console.error("Error processing WebSocket message:", error);
105 |         }
106 |       });
107 |     });
108 |   }
109 | 
110 |   private executeCommand(command: string, callback: (output: string) => void) {
111 |     console.error(`Executing command: ${command}`);
112 | 
113 |     const process = spawn("bash", ["-c", command]);
114 | 
115 |     process.stdout.on("data", (data: Buffer) => {
116 |       callback(data.toString());
117 |     });
118 | 
119 |     process.stderr.on("data", (data: Buffer) => {
120 |       callback(data.toString());
121 |     });
122 | 
123 |     process.on("error", (error: Error) => {
124 |       callback(`Error: ${error.message}`);
125 |     });
126 | 
127 |     process.on("close", (code: number | null) => {
128 |       callback(`Command exited with code ${code}`);
129 |     });
130 |   }
131 | 
132 |   private setupErrorHandling() {
133 |     this.server.onerror = (error) => {
134 |       console.error("[MCP Error]", error);
135 |     };
136 | 
137 |     process.on("SIGINT", async () => {
138 |       if (this.tunnel) {
139 |         this.tunnel.close();
140 |       }
141 |       await this.server.close();
142 |       this.webServer.close();
143 |       process.exit(0);
144 |     });
145 |   }
146 | 
147 |   private setupHandlers() {
148 |     this.setupToolHandlers();
149 |   }
150 | 
151 |   private setupToolHandlers() {
152 |     this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
153 |       tools: [
154 |         {
155 |           name: "execute_command",
156 |           description: "Execute a shell command on the VM",
157 |           inputSchema: zodToJsonSchema(shellCommandSchema)
158 |         },
159 |         {
160 |           name: "start_tunnel",
161 |           description: "Start a web tunnel to access the VM interface",
162 |           inputSchema: zodToJsonSchema(tunnelConfigSchema)
163 |         }
164 |       ]
165 |     }));
166 | 
167 |     this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
168 |       switch (request.params.name) {
169 |         case "execute_command":
170 |           return this.handleExecuteCommand(request);
171 |         case "start_tunnel":
172 |           return this.handleStartTunnel(request);
173 |         default:
174 |           throw new McpError(
175 |             ErrorCode.MethodNotFound,
176 |             `Unknown tool: ${request.params.name}`
177 |           );
178 |       }
179 |     });
180 |   }
181 | 
182 |   private async handleExecuteCommand(request: any) {
183 |     const parsed = shellCommandSchema.safeParse(request.params.arguments);
184 |     if (!parsed.success) {
185 |       throw new McpError(ErrorCode.InvalidParams, "Invalid command arguments");
186 |     }
187 | 
188 |     const { command } = parsed.data;
189 | 
190 |     return new Promise<any>((resolve) => {
191 |       let output = "";
192 | 
193 |       this.executeCommand(command, (data) => {
194 |         output += data;
195 |       });
196 | 
197 |       // Simple timeout to collect output
198 |       setTimeout(() => {
199 |         resolve({
200 |           content: [
201 |             {
202 |               type: "text",
203 |               text:
204 |                 output ||
205 |                 "Command executed (no output or still running in background)"
206 |             }
207 |           ]
208 |         });
209 |       }, 2000);
210 |     });
211 |   }
212 | 
213 |   private async handleStartTunnel(request: any) {
214 |     const parsed = tunnelConfigSchema.safeParse(request.params.arguments);
215 |     if (!parsed.success) {
216 |       throw new McpError(
217 |         ErrorCode.InvalidParams,
218 |         "Invalid tunnel configuration"
219 |       );
220 |     }
221 | 
222 |     const { port, subdomain } = parsed.data;
223 |     this.serverPort = port;
224 | 
225 |     // Close existing tunnel if any
226 |     if (this.tunnel) {
227 |       this.tunnel.close();
228 |     }
229 | 
230 |     try {
231 |       // Create the tunnel
232 |       const tunnelOptions: any = {
233 |         port: this.serverPort
234 |       };
235 | 
236 |       if (subdomain) {
237 |         tunnelOptions.subdomain = subdomain;
238 |       }
239 | 
240 |       this.tunnel = await localtunnel(tunnelOptions);
241 |       this.tunnelUrl = this.tunnel.url;
242 | 
243 |       return {
244 |         content: [
245 |           {
246 |             type: "text",
247 |             text: `Tunnel created successfully. VM interface available at: ${this.tunnelUrl}`
248 |           }
249 |         ]
250 |       };
251 |     } catch (error: any) {
252 |       throw new McpError(
253 |         ErrorCode.InternalError,
254 |         `Failed to create tunnel: ${error.message || String(error)}`
255 |       );
256 |     }
257 |   }
258 | 
259 |   mcpTransportStart = async () => {
260 |     this.app.get("/sse", async (req: any, res: any) => {
261 |       this.transport = new SSEServerTransport("/messages", res);
262 |       await this.server.connect(this.transport);
263 |     });
264 | 
265 |     this.app.post("/messages", async (req: any, res: any) => {
266 |       // Note: to support multiple simultaneous connections, these messages will
267 |       // need to be routed to a specific matching transport. (This logic isn't
268 |       // implemented here, for simplicity.)
269 |       await this.transport.handlePostMessage(req, res);
270 |     });
271 |   };
272 | 
273 |   async run() {
274 |     // Start the MCP server
275 |     await this.mcpTransportStart();
276 |     // Start the web server
277 |     this.webServer.listen(this.serverPort, async () => {
278 |       console.log(
279 |         `Web server running on port http://localhost:${this.serverPort}`
280 |       );
281 | 
282 |       // Auto-start tunnel unless --no-tunnel flag is provided
283 |       if (!this.noTunnel) {
284 |         await this.startTunnelOnBoot().catch((err) => {
285 |           console.error("Failed to start tunnel on boot:", err.message);
286 |         });
287 |       }
288 |     });
289 | 
290 |     console.error("VM MCP server running on stdio");
291 |   }
292 | 
293 |   private async startTunnelOnBoot() {
294 |     try {
295 |       // Create the tunnel
296 |       const tunnelOptions: any = {
297 |         port: this.serverPort
298 |       };
299 | 
300 |       // Optional subdomain from environment variable
301 |       const subdomain = process.env.LOCALTUNNEL_SUBDOMAIN;
302 |       if (subdomain) {
303 |         tunnelOptions.subdomain = subdomain;
304 |       }
305 | 
306 |       this.tunnel = await localtunnel(tunnelOptions);
307 |       this.tunnelUrl = this.tunnel.url;
308 | 
309 |       console.error(
310 |         `Tunnel created automatically. VM interface available at: ${this.tunnelUrl}`
311 |       );
312 | 
313 |       return this.tunnelUrl;
314 |     } catch (error: any) {
315 |       console.error(
316 |         `Failed to create tunnel: ${error.message || String(error)}`
317 |       );
318 |       throw error;
319 |     }
320 |   }
321 | }
322 | 
323 | const server = new VmMcpServer();
324 | server.run().catch(console.error);
325 | 
```