mech.app

The mech.app newsletter

Agentic AI, minus the noise.

Get practical field notes on AI agents, automation, developer tools and security delivered to your inbox.

No spam. Unsubscribe anytime.

Dev Tools

Secure MCP Servers for VPS Operations: How to Give Agents SSH Access Without Giving Them a Shell

Build MCP servers that expose SSH operations through allowlisted tools, not arbitrary commands. Command boundaries, audit trails, and shell escape preve...

Source: dev.to
Secure MCP Servers for VPS Operations: How to Give Agents SSH Access Without Giving Them a Shell

Giving an AI agent SSH access to a VPS is a bad idea if you do it wrong. The naive approach is to expose a shell and let the agent run arbitrary commands. That turns your infrastructure into a probabilistic execution environment controlled by a language model.

The better pattern is to build a Model Context Protocol server that exposes narrowly scoped operations, not a general-purpose shell. Each operation maps to a fixed script on the VPS. The agent can call get_system_health or list_containers, but it cannot run rm -rf / or chain commands with shell metacharacters.

This article walks through the plumbing: how to allowlist SSH commands, where to draw the boundary between tool and arbitrary execution, how to audit agent operations, and what happens when an agent tries to escape the sandbox.

The Security Problem with Shell Access

If you expose a shell to an agent, you are trusting the model to never hallucinate a destructive command. You are also trusting every prompt injection attack to fail. You are betting that no user will ever ask the agent to “clean up old logs” in a way that accidentally wipes production data.

The attack surface is not just the model. It includes:

  • User prompts that trick the agent into running dangerous commands
  • Model hallucinations that produce syntactically valid but semantically wrong operations
  • Chained commands using &&, ||, or ; that bypass intent
  • Shell metacharacters like $(...) or backticks that execute subshells
  • Environment variable injection through unsanitized arguments

You cannot solve this by prompt engineering. You need a hard boundary at the infrastructure layer.

Architecture: Fixed Operations, Not Free-Form Commands

The MCP server sits between the AI assistant and the VPS. It accepts structured JSON requests and translates them into allowlisted SSH commands. The flow looks like this:

AI Assistant

MCP Client (stdio)

Local MCP Server (Python)

SSH Key Authentication

Dedicated VPS User (no sudo)

Command Gateway (JSON → allowlisted script)

Root-Owned Scripts (immutable, audited)

The MCP server defines tools like get_system_health, get_disk_usage, list_containers, get_container_logs, get_service_status, check_nginx_configuration, check_ssl_expiry, and check_database_health. Each tool maps to a single script on the VPS.

The agent sends a request like this:

{
  "operation": "system.health",
  "arguments": {}
}

Or this:

{
  "operation": "docker.logs",
  "arguments": {
    "container": "grafana",
    "lines": 50
  }
}

It never sends this:

{
  "command": "docker logs grafana | grep ERROR"
}

The MCP server rejects any request that does not match a known operation. It does not parse shell syntax. It does not accept arbitrary strings.

Implementation: Allowlisted Scripts and Argument Validation

On the VPS, each operation is a standalone script owned by root with permissions 0755. The dedicated SSH user can execute these scripts but cannot modify them. The scripts are stored in /opt/mcp-tools/.

Example script for system.health:

#!/bin/bash
# /opt/mcp-tools/system-health.sh

set -euo pipefail

uptime_info=$(uptime)
load_avg=$(cat /proc/loadavg | awk '{print $1, $2, $3}')
memory_info=$(free -h | grep Mem)
cpu_count=$(nproc)

echo "{"
echo "  \"uptime\": \"$uptime_info\","
echo "  \"load_average\": \"$load_avg\","
echo "  \"memory\": \"$memory_info\","
echo "  \"cpu_count\": $cpu_count"
echo "}"

The MCP server validates arguments before passing them to scripts. For docker.logs, it checks that container is alphanumeric and that lines is an integer between 1 and 1000. It does not allow shell metacharacters in any argument.

def validate_container_name(name: str) -> bool:
    return bool(re.match(r'^[a-zA-Z0-9_-]+$', name))

def validate_line_count(count: int) -> bool:
    return 1 <= count <= 1000

If validation fails, the MCP server returns an error to the agent. It does not attempt to sanitize input. Sanitization is a losing game. Validation is a hard boundary.

SSH Session Management and Key Rotation

The MCP server uses SSH key authentication, not passwords. The private key is stored locally and never sent over the network. The VPS user has a dedicated public key in ~/.ssh/authorized_keys.

For long-running agent workflows, the MCP server reuses a single SSH connection with ControlMaster and ControlPersist. This avoids the overhead of establishing a new connection for every tool call.

SSH config:

Host vps-mcp
  HostName 203.0.113.42
  User mcp-agent
  IdentityFile ~/.ssh/mcp-agent-key
  ControlMaster auto
  ControlPath ~/.ssh/mcp-%r@%h:%p
  ControlPersist 10m

Key rotation happens out-of-band. The MCP server does not have permission to rotate its own key. A separate automation job rotates keys weekly and updates both the local private key and the remote authorized_keys file.

Audit Trail: What to Log and What to Redact

Every tool call is logged locally before execution. The log includes:

  • Timestamp
  • Tool name
  • Arguments (sanitized)
  • Result status (success, failure, timeout)
  • Execution time

The log does not include:

  • SSH private keys
  • Environment variables
  • Full command output (only summary or error)
  • User prompts (those belong to the AI client, not the MCP server)

Example log entry:

{
  "timestamp": "2026-08-01T20:15:42Z",
  "tool": "get_container_logs",
  "arguments": {
    "container": "grafana",
    "lines": 50
  },
  "status": "success",
  "execution_time_ms": 342
}

On the VPS side, each script logs to syslog with a tag like mcp-tool[system-health]. This creates a second audit trail that is independent of the MCP server.

Failure Modes and Shell Escape Attempts

The most common failure mode is the agent trying to chain commands. If the agent sends:

{
  "operation": "docker.logs",
  "arguments": {
    "container": "grafana && rm -rf /tmp/*",
    "lines": 50
  }
}

The validation step rejects container because it contains &&. The MCP server returns an error. The agent does not get a shell.

Another failure mode is the agent trying to use subshells:

{
  "operation": "docker.logs",
  "arguments": {
    "container": "$(whoami)",
    "lines": 50
  }
}

Again, validation rejects container because it contains $(. No execution happens.

A subtler failure mode is the agent trying to exploit argument injection in the script itself. If the script does this:

docker logs $CONTAINER --tail $LINES

And the agent sends container: "grafana --rm", it might trick the script into running docker logs grafana --rm --tail 50. This is why scripts should use "$CONTAINER" (quoted) and validate arguments before passing them to commands.

The safest pattern is to use positional arguments and avoid variable interpolation entirely:

#!/bin/bash
set -euo pipefail

CONTAINER="$1"
LINES="$2"

if [[ ! "$CONTAINER" =~ ^[a-zA-Z0-9_-]+$ ]]; then
  echo "Invalid container name" >&2
  exit 1
fi

if [[ ! "$LINES" =~ ^[0-9]+$ ]]; then
  echo "Invalid line count" >&2
  exit 1
fi

docker logs "$CONTAINER" --tail "$LINES"

Trade-Offs: Flexibility vs. Security

ApproachFlexibilitySecurityAudit ComplexityMaintenance
Raw shell accessHighNoneImpossibleLow
Allowlisted scriptsMediumHighModerateMedium
API-only (no SSH)LowHighestLowHigh
Sudo with command restrictionsMediumMediumHighHigh

Allowlisted scripts are the sweet spot for VPS operations. You get enough flexibility to handle common tasks (health checks, log inspection, service restarts) without exposing a general-purpose execution environment. The audit trail is manageable because you know exactly which operations are possible. Maintenance is moderate because you need to add new scripts when the agent needs new capabilities.

API-only approaches (like using cloud provider APIs instead of SSH) are more secure but require that every operation has a corresponding API endpoint. For self-hosted VPS infrastructure, that is often not the case.

Sudo with command restrictions (using /etc/sudoers to allowlist specific commands) is harder to audit because sudo logs are verbose and difficult to correlate with agent actions. It also requires careful configuration to avoid privilege escalation.

When the Agent Needs a New Capability

If the agent asks to do something that is not covered by an existing tool, you have two options:

  1. Add a new allowlisted script and expose it as a new MCP tool
  2. Tell the agent it cannot do that operation

The second option is underrated. Not every operation belongs in an agent workflow. Some tasks require human judgment, especially destructive operations like deleting data or modifying production configuration.

If you do add a new script, the process is:

  1. Write the script locally and test it manually
  2. Review the script for argument injection vulnerabilities
  3. Deploy the script to the VPS with root ownership and 0755 permissions
  4. Add the script to the MCP server’s tool registry
  5. Update the audit log schema to include the new tool
  6. Test the tool with the agent in a staging environment

This is slower than giving the agent a shell, but it is also safer. The friction is intentional.

Technical Verdict

Use this pattern when you need to give an AI agent limited access to VPS operations without exposing a shell. It works well for health checks, log inspection, container management, and service status queries. It does not work well for exploratory debugging or ad-hoc operations that require chaining multiple commands.

Avoid this pattern if you need the agent to perform complex, multi-step workflows that cannot be decomposed into fixed operations. In that case, consider building a higher-level orchestration layer that sequences multiple tool calls instead of exposing a shell.

The key insight is that the boundary between “tool” and “arbitrary command” is a security boundary, not a usability feature. Treat it like you would treat any other privilege boundary in your infrastructure.

Tags

agentic-ai orchestration infrastructure

Primary Source

dev.to