# 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 |
```