# Building a Secure MCP Server for AI-Assisted VPS Operations Without Giving the AI a Shell

> Source: <https://dev.to/ojo_ilesanmi/building-a-secure-mcp-server-for-ai-assisted-vps-operations-without-giving-the-ai-a-shell-54l3>
> Published: 2026-08-01 19:31:59+00:00

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.
