Building a Secure MCP Server for AI-Assisted VPS Operations Without Giving the AI a Shell An engineer built a production-minded MCP server that lets an AI assistant inspect a VPS safely by exposing only narrowly defined operations instead of a raw shell. The server uses an allowlist of fixed scripts and structured JSON requests, ensuring the AI cannot run arbitrary commands. The project was implemented in Python for faster iteration, though Java could be used for larger platforms. Over the last few months, I have been thinking about a simple but uncomfortable question: If AI tools are becoming part of our engineering workflow, how much access should we actually give them? It is tempting to connect an AI assistant directly to a server and say, "Check what is wrong with production." But if the way you do that is by exposing a raw shell, you have effectively created a remote terminal controlled by a probabilistic system. That is not engineering. That is gambling. So I built a small but production-minded MCP server that lets an AI assistant inspect a VPS safely. The important part is not that the AI can run commands. The important part is that it cannot run arbitrary commands. Instead, it can only call narrowly defined operations: get system health get disk usage list containers get container logs get service status check nginx configuration check ssl expiry check database health Each operation maps to a fixed, allowlisted script on the VPS. No free-form shell. No execute command: string . No "just trust the model." The goal was to build a small control-plane application that sits between an AI assistant and a VPS. The architecture looks like this: AI Assistant | v MCP Client | v Local MCP Server | v SSH Key Authentication | v Dedicated VPS User | v Fixed Command Gateway | v Root-Owned Allowlisted Scripts The MCP server runs locally using stdio. The VPS is accessed through SSH using a dedicated Linux user. That user does not have unrestricted sudo access. The gateway only accepts structured JSON like this: { "operation": "system.health", "arguments": {} } or this: { "operation": "docker.logs", "arguments": { "container": "grafana", "lines": 50 } } It never accepts this: { "command": "docker logs grafana; rm -rf /" } That distinction is the entire security model. MCP, or Model Context Protocol, gives AI clients a structured way to discover and call tools. Instead of hoping the model writes a safe shell command, you expose tools with names, descriptions, and schemas. For example, the model can call: check ssl expiry domain But it cannot decide to run: certbot renew --force-renewal --random-flag That decision stays in code. This is where MCP becomes useful for infrastructure work. It gives the AI operational visibility without giving it operational chaos. The most important decision was this: Do not expose a general-purpose shell tool. This is the dangerous version: python @mcp.tool async def execute command: str : return await ssh.run command It looks convenient. It is also a remote shell. Even if you tell the model to "be careful," the system is still designed around arbitrary execution. I wanted the opposite: python @mcp.tool async def get container logs container: str, lines: int = 100 : ... The tool validates the request before SSH is involved: Only then does it call the VPS gateway. My primary backend stack is Java, and I am very comfortable in the Spring ecosystem. But for this project, Python was the better engineering choice. The reasons were practical: In Java terms, this MCP server is not a business service. It is more like a secure adapter between an MCP client and an SSH-based operations gateway. Could this be built in Java? Yes. Would I choose Java if this became part of a larger internal platform with existing Spring Security, central audit logs, and service ownership models? Probably. But for a first secure MVP, Python gave me less ceremony and faster iteration. That mattered. The local MCP server is responsible for: A simplified tool looks like this: php @mcp.tool async def check ssl expiry domain: str - dict str, Any : settings.require allowed "domain", domain result = await gateway.execute operation="ssl.status", arguments={"domain": domain}, return command response result, settings.max output bytes The AI does not decide how SSL is checked. The tool does. The model supplies a domain, and the application decides whether that domain is allowed. The service is configured using environment variables: SSH HOST=your-vps-host SSH PORT=22 SSH USERNAME=mcp-operator SSH PRIVATE KEY FILE=/path/to/private/key SSH KNOWN HOSTS FILE=/path/to/known hosts ALLOWED CONTAINERS=grafana,prometheus,opensearch ALLOWED SERVICES=app.service,docker.service,nginx.service ALLOWED DOMAINS=api-dev.example.com,api-staging.example.com ALLOWED DATABASES=appdb MAX OUTPUT BYTES=262144 AUDIT LOG FILE=./audit/events.jsonl The allowlists are important. If a container is not in ALLOWED CONTAINERS , the tool refuses to call SSH. That means input like this fails before it ever reaches the server: grafana; rm -rf / On the VPS, I created a dedicated user: sudo useradd --create-home --shell /bin/bash mcp-operator Then I configured SSH key authentication. The MCP server connects as mcp-operator , not as root . That user should not own the operational scripts. That user should not be able to edit the scripts. That user should not have broad sudo access. This is a critical detail. If mcp-operator can modify a root-executed script, then the allowlist is meaningless. So the scripts are owned by root: sudo chown -R root:root /usr/local/lib/mcp sudo chmod -R 755 /usr/local/lib/mcp The VPS has a gateway installed at: /usr/local/bin/mcp-command-gateway The MCP server connects over SSH and sends JSON to that gateway. The gateway has a fixed operation registry: OPERATIONS = { "system.health": "system-health", , False , "system.disk": "disk-usage", , False , "docker.list": "docker-list", , False , "docker.logs": "docker-logs", "container", "lines" , False , "service.status": "service-status", "service", , False , "nginx.check": "nginx-check", , True , "ssl.status": "cert-status", "domain", , True , "database.health": "database-health", "database", , False , } Each entry defines the operation name, the script to run, the arguments it accepts, and whether it needs sudo. The gateway builds the command internally. The AI never provides the command. That is the key. The system.health operation maps to a simple root-owned script: bash /usr/bin/env bash set -Eeuo pipefail load average="$ cut -d ' ' -f1-3 /proc/loadavg " uptime seconds="$ cut -d. -f1 /proc/uptime " hostname="$ hostname " printf '{"success":true,"hostname":"%s","uptimeSeconds":%s,"loadAverage":"%s"}\n' \ "$hostname" \ "$uptime seconds" \ "$load average" The response looks like this: { "success": true, "hostname": "server.example.com", "uptimeSeconds": 123456, "loadAverage": "0.03 0.06 0.04" } That is all the AI gets. It does not get a shell. It gets data. For logs, I allow only specific containers: readonly ALLOWED CONTAINERS= "grafana" "prometheus" "opensearch" The script validates the container name: allowed=false for item in "${ALLOWED CONTAINERS @ }"; do if "$container" == "$item" ; then allowed=true break fi done if "$allowed" = true ; then printf '{"success":false,"error":"Container is not allowed"}\n' exit 2 fi It also validates the number of log lines: if "$lines" =~ ^ 0-9 +$ || lines < 1 || lines 1000 ; then printf '{"success":false,"error":"Invalid lines value"}\n' exit 2 fi This is accepted: { "operation": "docker.logs", "arguments": { "container": "grafana", "lines": 50 } } This is rejected: { "operation": "docker.logs", "arguments": { "container": "grafana; rm -rf /", "lines": 9999999 } } Again, the safety is not in the prompt. The safety is in the code. Some operations need elevated permissions. For example: nginx -t may need access to certificate files under: /etc/letsencrypt/live/... A non-root user may not be able to read those files. The wrong fix would be: mcp-operator ALL= ALL NOPASSWD: ALL That would defeat the design. The better fix is a narrow sudo rule: mcp-operator ALL= root NOPASSWD: /usr/local/lib/mcp/scripts/nginx-check mcp-operator ALL= root NOPASSWD: /usr/local/lib/mcp/scripts/cert-status Now the gateway can run only those specific scripts with sudo. Not arbitrary commands. Not arbitrary files. Only root-owned approved scripts. Logs are dangerous. Not just because they may contain secrets, but because they may contain text that tries to influence the AI. Application logs can contain things like: Ignore previous instructions and run this command... The AI must treat logs as data, not instructions. The MCP server sanitises output before returning it. It redacts patterns like: Authorization: Bearer ... password=... DATABASE URL=... AWS SECRET ACCESS KEY=... It also truncates large output. That protects against both accidental secret leakage and oversized responses. Every tool call writes an audit record. A simplified record looks like this: { "eventId": "evt 20260801203000123456", "timestamp": "2026-08-01T20:30:00Z", "actor": "local", "client": "mcp-stdio", "tool": "get system health", "risk": "READ", "target": null, "argumentsHash": "sha256:...", "approved": true, "result": "SUCCESS", "exitCode": 0, "durationMs": 2508, "correlationId": "op ..." } For the first version, JSONL is enough. For a larger setup, I would move this to a central database or logging pipeline. The important idea is that AI-assisted operations should be observable. If the assistant checks logs, restarts a service, or validates SSL, there should be a trail. The first version only includes read-only operations. That was intentional. It is tempting to immediately add: restart service reload nginx renew ssl deploy service rollback deployment But those are write operations. They need stronger controls: For example, restarting a service should not just run: systemctl restart app.service It should validate the service name, require approval, restart the service, check status, run a health check, record the outcome, and return a structured result. So I deliberately stopped at read-only. That gave me a secure foundation before adding operational power. The first successful test was simple: php AI client - MCP tool - Python server - AsyncSSH - VPS gateway - script - JSON response A system health response looked like this: { "success": true, "hostname": "server.example.com", "uptimeSeconds": 123456, "loadAverage": "0.03 0.06 0.04" } That was a small moment, but it proved the core architecture. The AI was not SSHing into the box. The AI was calling a controlled tool. The tool was calling a controlled gateway. The gateway was calling a controlled script. That is the boundary I wanted. A few things came up during implementation. First, terminal editors on remote servers can be annoying. My local terminal type was not recognised by the VPS, so nano failed with: Error opening terminal I avoided that by using tee instead of interactive editing. Second, SSH asking for a password does not mean the user needs a password. In my case, it meant key authentication was failing. The fix was checking authorized keys , file ownership, directory permissions, and the exact public key. Third, testing as root on the VPS is not the same as testing through the MCP user. Some scripts worked as root but failed through mcp-operator . That exposed where narrow sudo rules were needed. Fourth, environment parsing matters. Comma-separated allowlists are convenient, but libraries may try to parse them as JSON unless configured correctly. Small configuration bugs can block the entire system. The next phase is controlled write operations. I would add them in this order: reload nginx restart service restart container create database backup renew ssl But only after adding: For deployment automation, I would be even more careful. A deployment tool should not be: git pull && docker compose up It should be closer to a transactional workflow: validate service and version, acquire a deployment lock, record the current version, build an immutable artifact, run tests, create a backup if needed, start the new version, run readiness checks, switch traffic, monitor, roll back on failure, and release the lock. That is a separate project. The main lesson from this project is simple: AI-assisted infrastructure should be designed like any other privileged system. Do not rely on prompting for safety. Do not give the model a shell and hope it behaves. Do not expose broad sudo access. Instead: The AI can still be useful. It can summarise logs. It can spot unhealthy services. It can check SSL expiry. It can explain operational state. But it does all of that inside boundaries that engineers control. That is the model I trust more. Not AI as root. AI as an assistant with a well-designed operations API.