# 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:
--------------------------------------------------------------------------------
```
# Dependencies
/node_modules
# Production build files
/dist
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Local env files
.env*
!.env.example
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# OS files
.DS_Store
Thumbs.db
dist
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
# MCP Tunnel
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.
## Features
- Execute shell commands on a VM through MCP
- Web-based terminal interface for VM interaction
- Automatic tunneling to make the VM accessible from anywhere
- WebSocket-based real-time communication
## Prerequisites
- Node.js (v18 or newer)
## Installation and Usage
### Running with npx (no installation)
```bash
npx mcp-cli
```
### Global Installation
```bash
npm install -g mcp-cli
mcp-cli
```
### Local Development
```bash
# Clone repository
git clone [repository-url]
cd mcp-cli
# Install dependencies
npm install
```
## Development
Run the development server with hot-reloading for both backend and frontend:
```bash
npm run dev
```
## Building
Build both the frontend and backend for production:
```bash
npm run build-all
```
## Usage
1. Start the MCP server:
```bash
# Start with automatic tunneling
npm start
# Start without automatic tunneling
npm start -- --no-tunnel
```
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.
2. The server will start and provide output on stderr (to avoid interfering with MCP communication on stdout)
3. Use MCP to interact with the server using the following tools:
### Available MCP Tools
- `execute_command`: Run a shell command on the VM
- Parameters: `{ "command": "your shell command" }`
- `start_tunnel`: Create a web tunnel to access the VM interface
- Parameters: `{ "port": 8080, "subdomain": "optional-subdomain" }`
## Web Interface
After starting the tunnel, you can access the web-based terminal interface at the URL provided by the tunnel. This interface allows you to:
- Execute commands directly in the VM
- See command outputs in real-time
- Interact with the VM from any device with web access
## Environment Variables
Create a `.env` file to configure the server:
```
# Server configuration
PORT=8080
# Localtunnel configuration
LOCALTUNNEL_SUBDOMAIN=your-preferred-subdomain
```
## Security Considerations
This tool provides direct access to your VM's command line. Consider these security practices:
- Use strong authentication mechanisms before exposing the tunnel
- Limit the commands that can be executed through proper validation
- Consider running in a restricted environment
- Do not expose sensitive information through the tunnel
```
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
```javascript
import { defineConfig } from 'vite';
export default defineConfig({
root: './frontend/src',
publicDir: '../public',
server: {
port: 3000,
},
build: {
outDir: '../../dist',
emptyOutDir: true,
sourcemap: true,
},
});
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["index.ts"],
"exclude": ["node_modules", "dist", "frontend"]
}
```
--------------------------------------------------------------------------------
/frontend/src/index.html:
--------------------------------------------------------------------------------
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VM Terminal</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<div class="header">
<div>VM Terminal</div>
<button id="clear-btn">Clear</button>
</div>
<div id="terminal-container"></div>
<script type="module" src="./main.js"></script>
</body>
</html>
```
--------------------------------------------------------------------------------
/frontend/src/style.css:
--------------------------------------------------------------------------------
```css
body {
margin: 0;
padding: 0;
height: 100vh;
display: flex;
flex-direction: column;
background-color: #1e1e1e;
}
#terminal-container {
flex: 1;
padding: 10px;
height: calc(100vh - 20px);
}
#terminal-container .terminal {
height: 100%;
}
.header {
background-color: #282828;
color: #f0f0f0;
padding: 5px 10px;
font-family: sans-serif;
font-size: 14px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header button {
background-color: #3a3a3a;
color: #f0f0f0;
border: none;
padding: 5px 10px;
border-radius: 3px;
cursor: pointer;
}
.header button:hover {
background-color: #4a4a4a;
}
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
{
"name": "mcp-cli",
"version": "1.0.0",
"description": "MCP server for accessing VM command line with web tunneling",
"main": "dist/index.js",
"type": "module",
"bin": {
"mcp-tunnel": "./dist/index.js"
},
"scripts": {
"start": "npm run build-all && node dist/index.js",
"build-all": "npm run build-frontend && npm run build-server",
"build-frontend": "vite build",
"build-server": "tsc",
"dev": "concurrently \"ts-node index.ts\" \"vite --host\"",
"preview": "vite preview",
"prepare": "npm run build-all"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.7.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"dotenv": "^16.4.1",
"express": "^5.0.1",
"localtunnel": "^2.0.2",
"typescript": "^5.8.2",
"ws": "^8.16.0",
"zod": "^3.22.4",
"zod-to-json-schema": "^3.22.3"
},
"engines": {
"node": ">=18.0.0"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/localtunnel": "^2.0.4",
"@types/node": "^22.13.10",
"@types/ws": "^8.18.0",
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^9.1.2",
"ts-node": "^10.9.2",
"vite": "^6.2.2"
},
"publishConfig": {
"access": "public"
},
"keywords": [
"mcp",
"tunnel",
"terminal",
"vm",
"cli"
]
}
```
--------------------------------------------------------------------------------
/frontend/src/main.js:
--------------------------------------------------------------------------------
```javascript
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links';
import '@xterm/xterm/css/xterm.css';
const term = new Terminal({
cursorBlink: true,
theme: {
background: "#1e1e1e",
foreground: "#f0f0f0",
cursor: "#f0f0f0",
selectionBackground: "#565656"
},
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
fontSize: 14,
lineHeight: 1.2,
scrollback: 5000,
cursorStyle: "block"
});
// Add addons
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddon());
term.open(document.getElementById("terminal-container"));
fitAddon.fit();
term.focus();
// Handle window resize
window.addEventListener("resize", () => {
fitAddon.fit();
});
// Clear button functionality
document.getElementById("clear-btn").addEventListener("click", () => {
term.clear();
});
let ws;
let commandBuffer = "";
let commandHistory = [];
let historyPosition = -1;
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
ws.onopen = () => {
term.writeln("\r\nConnected to VM terminal");
term.write("\r\n$ ");
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === "output") {
term.write(data.content);
if (!data.content.endsWith("\n")) {
term.write("\r\n");
}
term.write("$ ");
}
};
ws.onclose = () => {
term.writeln("\r\nConnection lost. Reconnecting...");
setTimeout(connectWebSocket, 2000);
};
ws.onerror = (error) => {
console.error("WebSocket error:", error);
term.writeln("\r\nConnection error. Please try again.");
};
}
function clearCurrentLine() {
const currentLine = commandBuffer;
for (let i = 0; i < currentLine.length; i++) {
term.write("\b \b");
}
return currentLine;
}
term.onKey(({ key, domEvent }) => {
const printable =
!domEvent.altKey && !domEvent.ctrlKey && !domEvent.metaKey;
if (domEvent.keyCode === 13) {
// Enter key
term.write("\r\n");
if (commandBuffer.trim() !== "") {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "command",
command: commandBuffer
})
);
// Add to history if not duplicate
if (
commandHistory.length === 0 ||
commandHistory[commandHistory.length - 1] !== commandBuffer
) {
commandHistory.push(commandBuffer);
}
historyPosition = -1;
} else {
term.writeln("Not connected to the server.");
term.write("$ ");
}
} else {
term.write("$ ");
}
commandBuffer = "";
} else if (domEvent.keyCode === 8) {
// Backspace
if (commandBuffer.length > 0) {
commandBuffer = commandBuffer.slice(0, -1);
term.write("\b \b");
}
} else if (domEvent.keyCode === 38) {
// Up arrow - History previous
if (commandHistory.length > 0) {
if (historyPosition === -1) {
historyPosition = commandHistory.length - 1;
} else if (historyPosition > 0) {
historyPosition--;
}
clearCurrentLine();
commandBuffer = commandHistory[historyPosition];
term.write(commandBuffer);
}
} else if (domEvent.keyCode === 40) {
// Down arrow - History next
if (historyPosition !== -1) {
if (historyPosition < commandHistory.length - 1) {
historyPosition++;
clearCurrentLine();
commandBuffer = commandHistory[historyPosition];
term.write(commandBuffer);
} else {
historyPosition = -1;
clearCurrentLine();
commandBuffer = "";
}
}
} else if (domEvent.ctrlKey && key.toLowerCase() === "c") {
// Ctrl+C
term.write("^C\r\n$ ");
commandBuffer = "";
} else if (domEvent.ctrlKey && key.toLowerCase() === "l") {
// Ctrl+L (clear)
term.clear();
term.write("$ " + commandBuffer);
} else if (printable) {
commandBuffer += key;
term.write(key);
}
});
window.addEventListener("load", connectWebSocket);
```
--------------------------------------------------------------------------------
/terminal.html:
--------------------------------------------------------------------------------
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VM Terminal</title>
<script src="https://cdn.jsdelivr.net/npm/@xterm/[email protected]/lib/xterm.min.js"></script>
<link
href="https://cdn.jsdelivr.net/npm/@xterm/[email protected]/css/xterm.min.css"
rel="stylesheet"
/>
<script src="https://cdn.jsdelivr.net/npm/@xterm/[email protected]/lib/addon-fit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/[email protected]/lib/addon-web-links.min.js"></script>
<style>
body {
margin: 0;
padding: 0;
height: 100vh;
display: flex;
flex-direction: column;
background-color: #1e1e1e;
}
#terminal-container {
flex: 1;
padding: 10px;
height: calc(100vh - 20px);
}
#terminal-container .terminal {
height: 100%;
}
.header {
background-color: #282828;
color: #f0f0f0;
padding: 5px 10px;
font-family: sans-serif;
font-size: 14px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header button {
background-color: #3a3a3a;
color: #f0f0f0;
border: none;
padding: 5px 10px;
border-radius: 3px;
cursor: pointer;
}
.header button:hover {
background-color: #4a4a4a;
}
</style>
</head>
<body>
<div class="header">
<div>VM Terminal</div>
<button id="clear-btn">Clear</button>
</div>
<div id="terminal-container"></div>
<script type="module">
const term = new Terminal({
cursorBlink: true,
theme: {
background: "#1e1e1e",
foreground: "#f0f0f0",
cursor: "#f0f0f0",
selectionBackground: "#565656"
},
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
fontSize: 14,
lineHeight: 1.2,
scrollback: 5000,
cursorStyle: "block"
});
// Add addons
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddon());
term.open(document.getElementById("terminal-container"));
fitAddon.fit();
term.focus();
// Handle window resize
window.addEventListener("resize", () => {
fitAddon.fit();
});
// Clear button functionality
document.getElementById("clear-btn").addEventListener("click", () => {
term.clear();
});
let ws;
let commandBuffer = "";
let commandHistory = [];
let historyPosition = -1;
function connectWebSocket() {
ws = new WebSocket("ws://" + window.location.host);
ws.onopen = () => {
term.writeln("\r\nConnected to VM terminal");
term.write("\r\n$ ");
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === "output") {
term.write(data.content);
if (!data.content.endsWith("\n")) {
term.write("\r\n");
}
term.write("$ ");
}
};
ws.onclose = () => {
term.writeln("\r\nConnection lost. Reconnecting...");
setTimeout(connectWebSocket, 2000);
};
ws.onerror = (error) => {
console.error("WebSocket error:", error);
term.writeln("\r\nConnection error. Please try again.");
};
}
function clearCurrentLine() {
const currentLine = commandBuffer;
for (let i = 0; i < currentLine.length; i++) {
term.write("\b \b");
}
return currentLine;
}
term.onKey(({ key, domEvent }) => {
const printable =
!domEvent.altKey && !domEvent.ctrlKey && !domEvent.metaKey;
if (domEvent.keyCode === 13) {
// Enter key
term.write("\r\n");
if (commandBuffer.trim() !== "") {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "command",
command: commandBuffer
})
);
// Add to history if not duplicate
if (
commandHistory.length === 0 ||
commandHistory[commandHistory.length - 1] !== commandBuffer
) {
commandHistory.push(commandBuffer);
}
historyPosition = -1;
} else {
term.writeln("Not connected to the server.");
term.write("$ ");
}
} else {
term.write("$ ");
}
commandBuffer = "";
} else if (domEvent.keyCode === 8) {
// Backspace
if (commandBuffer.length > 0) {
commandBuffer = commandBuffer.slice(0, -1);
term.write("\b \b");
}
} else if (domEvent.keyCode === 38) {
// Up arrow - History previous
if (commandHistory.length > 0) {
if (historyPosition === -1) {
historyPosition = commandHistory.length - 1;
} else if (historyPosition > 0) {
historyPosition--;
}
clearCurrentLine();
commandBuffer = commandHistory[historyPosition];
term.write(commandBuffer);
}
} else if (domEvent.keyCode === 40) {
// Down arrow - History next
if (historyPosition !== -1) {
if (historyPosition < commandHistory.length - 1) {
historyPosition++;
clearCurrentLine();
commandBuffer = commandHistory[historyPosition];
term.write(commandBuffer);
} else {
historyPosition = -1;
clearCurrentLine();
commandBuffer = "";
}
}
} else if (domEvent.ctrlKey && key.toLowerCase() === "c") {
// Ctrl+C
term.write("^C\r\n$ ");
commandBuffer = "";
} else if (domEvent.ctrlKey && key.toLowerCase() === "l") {
// Ctrl+L (clear)
term.clear();
term.write("$ " + commandBuffer);
} else if (printable) {
commandBuffer += key;
term.write(key);
}
});
window.addEventListener("load", connectWebSocket);
</script>
</body>
</html>
```
--------------------------------------------------------------------------------
/index.ts:
--------------------------------------------------------------------------------
```typescript
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import {
ListToolsRequestSchema,
CallToolRequestSchema,
ErrorCode,
McpError
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
import { spawn } from "child_process";
import { createServer } from "http";
import { WebSocketServer } from "ws";
import { createReadStream, existsSync } from "fs";
import { join } from "path";
import { fileURLToPath } from "url";
import { dirname } from "path";
import dotenv from "dotenv";
import localtunnel from "localtunnel";
import express from "express";
dotenv.config();
// Schemas for our tools
const shellCommandSchema = z.object({
command: z.string().describe("Shell command to execute on the VM")
});
const tunnelConfigSchema = z.object({
port: z.number().default(8080).describe("Port to tunnel to the web"),
subdomain: z.string().optional().describe("Optional subdomain for the tunnel")
});
class VmMcpServer {
private server: Server;
private webServer: any;
private wss!: WebSocketServer;
private tunnel: any;
private tunnelUrl: string | undefined;
private serverPort = 8080;
private __dirname = dirname(fileURLToPath(import.meta.url));
private noTunnel = process.argv.includes("--no-tunnel");
private app: any;
private transport: any;
constructor() {
this.server = new Server(
{
name: "vm-mcp-server",
version: "0.1.0"
},
{
capabilities: {
resources: {},
tools: {}
}
}
);
this.setupHandlers();
this.setupErrorHandling();
this.setupWebServer();
}
private setupWebServer() {
this.app = express();
const distPath = join(this.__dirname, "/");
const devPath = join(this.__dirname, "frontend", "src");
// Check if we're in production (using built files) or development
const isProduction = existsSync(distPath);
const staticPath = isProduction ? distPath : devPath;
// Serve static files
this.app.use(express.static(staticPath));
// Fallback route for SPA
this.app.get("/", (req: any, res: any) => {
res.sendFile(join(staticPath, "index.html"));
});
// Create HTTP server from Express app
this.webServer = createServer(this.app);
// Create WebSocket server for real-time communication
this.wss = new WebSocketServer({
server: this.webServer,
path: "/ws"
});
this.wss.on("connection", (ws) => {
console.error("Client connected to WebSocket");
ws.on("message", (message) => {
try {
const data = JSON.parse(message.toString());
if (data.type === "command") {
this.executeCommand(data.command, (output) => {
ws.send(JSON.stringify({ type: "output", content: output }));
});
}
} catch (error) {
console.error("Error processing WebSocket message:", error);
}
});
});
}
private executeCommand(command: string, callback: (output: string) => void) {
console.error(`Executing command: ${command}`);
const process = spawn("bash", ["-c", command]);
process.stdout.on("data", (data: Buffer) => {
callback(data.toString());
});
process.stderr.on("data", (data: Buffer) => {
callback(data.toString());
});
process.on("error", (error: Error) => {
callback(`Error: ${error.message}`);
});
process.on("close", (code: number | null) => {
callback(`Command exited with code ${code}`);
});
}
private setupErrorHandling() {
this.server.onerror = (error) => {
console.error("[MCP Error]", error);
};
process.on("SIGINT", async () => {
if (this.tunnel) {
this.tunnel.close();
}
await this.server.close();
this.webServer.close();
process.exit(0);
});
}
private setupHandlers() {
this.setupToolHandlers();
}
private setupToolHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "execute_command",
description: "Execute a shell command on the VM",
inputSchema: zodToJsonSchema(shellCommandSchema)
},
{
name: "start_tunnel",
description: "Start a web tunnel to access the VM interface",
inputSchema: zodToJsonSchema(tunnelConfigSchema)
}
]
}));
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
switch (request.params.name) {
case "execute_command":
return this.handleExecuteCommand(request);
case "start_tunnel":
return this.handleStartTunnel(request);
default:
throw new McpError(
ErrorCode.MethodNotFound,
`Unknown tool: ${request.params.name}`
);
}
});
}
private async handleExecuteCommand(request: any) {
const parsed = shellCommandSchema.safeParse(request.params.arguments);
if (!parsed.success) {
throw new McpError(ErrorCode.InvalidParams, "Invalid command arguments");
}
const { command } = parsed.data;
return new Promise<any>((resolve) => {
let output = "";
this.executeCommand(command, (data) => {
output += data;
});
// Simple timeout to collect output
setTimeout(() => {
resolve({
content: [
{
type: "text",
text:
output ||
"Command executed (no output or still running in background)"
}
]
});
}, 2000);
});
}
private async handleStartTunnel(request: any) {
const parsed = tunnelConfigSchema.safeParse(request.params.arguments);
if (!parsed.success) {
throw new McpError(
ErrorCode.InvalidParams,
"Invalid tunnel configuration"
);
}
const { port, subdomain } = parsed.data;
this.serverPort = port;
// Close existing tunnel if any
if (this.tunnel) {
this.tunnel.close();
}
try {
// Create the tunnel
const tunnelOptions: any = {
port: this.serverPort
};
if (subdomain) {
tunnelOptions.subdomain = subdomain;
}
this.tunnel = await localtunnel(tunnelOptions);
this.tunnelUrl = this.tunnel.url;
return {
content: [
{
type: "text",
text: `Tunnel created successfully. VM interface available at: ${this.tunnelUrl}`
}
]
};
} catch (error: any) {
throw new McpError(
ErrorCode.InternalError,
`Failed to create tunnel: ${error.message || String(error)}`
);
}
}
mcpTransportStart = async () => {
this.app.get("/sse", async (req: any, res: any) => {
this.transport = new SSEServerTransport("/messages", res);
await this.server.connect(this.transport);
});
this.app.post("/messages", async (req: any, res: any) => {
// Note: to support multiple simultaneous connections, these messages will
// need to be routed to a specific matching transport. (This logic isn't
// implemented here, for simplicity.)
await this.transport.handlePostMessage(req, res);
});
};
async run() {
// Start the MCP server
await this.mcpTransportStart();
// Start the web server
this.webServer.listen(this.serverPort, async () => {
console.log(
`Web server running on port http://localhost:${this.serverPort}`
);
// Auto-start tunnel unless --no-tunnel flag is provided
if (!this.noTunnel) {
await this.startTunnelOnBoot().catch((err) => {
console.error("Failed to start tunnel on boot:", err.message);
});
}
});
console.error("VM MCP server running on stdio");
}
private async startTunnelOnBoot() {
try {
// Create the tunnel
const tunnelOptions: any = {
port: this.serverPort
};
// Optional subdomain from environment variable
const subdomain = process.env.LOCALTUNNEL_SUBDOMAIN;
if (subdomain) {
tunnelOptions.subdomain = subdomain;
}
this.tunnel = await localtunnel(tunnelOptions);
this.tunnelUrl = this.tunnel.url;
console.error(
`Tunnel created automatically. VM interface available at: ${this.tunnelUrl}`
);
return this.tunnelUrl;
} catch (error: any) {
console.error(
`Failed to create tunnel: ${error.message || String(error)}`
);
throw error;
}
}
}
const server = new VmMcpServer();
server.run().catch(console.error);
```