An MCP (Model Context Protocol) server that lets Claude (or any MCP client) control a real Ubuntu machine — shell, filesystem, services, processes, packages, network, cron, and users — through a configurable safety layer instead of raw, unchecked command execution.
Every tool call is evaluated by a YAML-driven intercept engine before anything runs: it classifies risk, checks the caller's permission level, and for dangerous actions creates a pending approval that a human reviews in a web UI before it's allowed to execute.
Giving an AI agent direct shell access to a server is powerful but risky — a single bad command (rm -rf, a careless sudo, an overwritten config) can take down a system with no chance to intervene. This project puts a human-in-the-loop approval gate between the AI and anything destructive, while still allowing safe, read-only operations to run instantly.
MCP Client → FastMCP → Tool Handler → Intercept Engine
│
Rule match (YAML patterns) → Risk level (SAFE/CAUTION/DANGEROUS/BLOCKED)
│
Permission check (read_only < operator < admin)
│
┌──────────┬───────────────┴───────────────┐
allow block confirm
│ │ │
Execute Audit + Alert PendingAction created (DB)
→ AI polls check_pending_action
→ Human approves/denies in Web UI
→ Action re-executes and returns the result
- Rules live in
config/rules/default_rules.yamlandconfig/rules/custom_rules.yaml, hot-reloaded every 30 seconds — no restart needed to change policy. - Every call is audited: command, arguments, risk level, outcome, stdout/stderr, and who triggered it.
- Alerting (Slack/PagerDuty-compatible webhook) can fire on blocked actions and on anything awaiting approval.
| Module | Example tools |
|---|---|
shell |
shell_execute, shell_script |
filesystem |
file_read, file_write, file_delete, file_move, file_copy, directory_list, file_search |
process |
process_list, process_info, process_kill, process_tree |
service |
service_status, service_start/stop/restart/reload, service_enable/disable, service_logs |
package |
package_install, package_remove, package_update, package_search, package_list |
network |
network_interfaces, network_routes, network_dns, firewall_status, firewall_add_rule, connection_list |
cron |
cron_list, cron_add, cron_remove, cron_validate |
users |
user_list, user_add, user_modify, user_delete, group_add/delete, sudo_rules_list |
pending |
check_pending_action — poll for a human's approve/deny decision |
# Python 3.12+ is required
python3 --version
# Install pip/venv if missing
sudo apt install python3-pip python3-venv -y
# (Optional) create a dedicated, less-privileged system user for isolated execution
sudo useradd -r -s /bin/bash mcp-runnerPasswordless sudo — the server runs commands without a TTY, so sudo can't prompt for a password. Grant passwordless sudo to whichever user will run the server (skip this if you don't need sudo commands to work):
echo 'youruser ALL=(ALL) NOPASSWD: ALL' | sudo tee /etc/sudoers.d/youruser
sudo chmod 440 /etc/sudoers.d/youruserReplace youruser with the actual account. Without this, any command that calls sudo fails with pam_unix(sudo:auth): auth could not identify password.
git clone <your-repo-url> /opt/mcp-server
cd /opt/mcp-server
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"cp .env.example .env
nano .envMinimum required changes:
# Generate a strong secret key: python3 -c "import secrets; print(secrets.token_hex(32))"
MCP_SECRET_KEY=paste-your-generated-key-here
# Optional: lock execution to a less-privileged user
MCP_EXECUTION_USER=mcp-runner
# Optional: Slack/PagerDuty webhook for blocked action alerts
MCP_ALERTING__WEBHOOK_URL=https://hooks.slack.com/services/...Leave everything else at defaults for a first run.
python scripts/create_user.py --username admin --password "your-strong-password" --role adminpython scripts/generate_api_key.py --name "claude-code" --permission adminThe script prints the raw key once — copy it now.
Foreground (test it first):
source .venv/bin/activate
python main.py
# Server is now at http://0.0.0.0:8000
# Web UI: http://YOUR_SERVER_IP:8000/ui/loginAs a systemd service (recommended for production):
sudo nano /etc/systemd/system/mcp-server.service[Unit]
Description=MCP Ubuntu Server
After=network.target
[Service]
Type=simple
User=your-user
WorkingDirectory=/opt/mcp-server
EnvironmentFile=/opt/mcp-server/.env
ExecStart=/opt/mcp-server/.venv/bin/python main.py
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now mcp-server
sudo systemctl status mcp-server# Allow the MCP port (only expose to trusted IPs in production)
sudo ufw allow 8000/tcp
sudo ufw reloadThere are two ways to connect, depending on where Claude runs relative to the server.
Use this when Claude Code runs on the same host as the server (or over SSH). No API key needed — stdio mode grants admin automatically.
// ~/.claude/claude_code_config.json
{
"mcpServers": {
"ubuntu-server": {
"command": "python",
"args": ["/opt/mcp-server/main.py", "--stdio"]
}
}
}Restart Claude Code and run /mcp to confirm tools like shell_execute and service_start show up under ubuntu-server.
Use this when Claude runs on a different machine than the server (e.g. your laptop → a server on the same LAN).
- Generate an API key (see step 5 above) if you haven't already.
- On the machine running Claude, install the local SSE bridge:
npm install -g mcp-remote(requires Node.js). This is needed because Claude Desktop's config schema doesn't support custom HTTP headers for SSE connections directly. - Configure Claude Desktop (
%APPDATA%\Claude\claude_desktop_config.jsonon Windows,~/Library/Application Support/Claude/claude_desktop_config.jsonon macOS):
{
"mcpServers": {
"ubuntu-server": {
"command": "mcp-remote",
"args": [
"http://YOUR_SERVER_IP:8000/sse-transport/sse?token=YOUR_API_KEY",
"--allow-http"
]
}
}
}Or, if you'd rather send the key as a header than a URL query param:
{
"mcpServers": {
"ubuntu-server": {
"command": "mcp-remote",
"args": [
"http://YOUR_SERVER_IP:8000/sse-transport/sse",
"--header",
"Authorization: Bearer YOUR_API_KEY",
"--allow-http"
]
}
}
}Fully quit and relaunch Claude Desktop after saving the config.
For Claude Code CLI connecting to a remote server, the headers field works directly and mcp-remote isn't needed:
{
"mcpServers": {
"ubuntu-server": {
"type": "sse",
"url": "http://YOUR_SERVER_IP:8000/sse-transport/sse",
"headers": { "Authorization": "Bearer YOUR_API_KEY" }
}
}
}--allow-http is fine on a trusted LAN; put HTTPS via a reverse proxy (nginx/caddy) in front if the server is reachable from the internet.
Note: if you're driving this from a Claude Code session running on a different machine than the target server (e.g. a Windows/Mac dev box controlling a remote Linux box), Claude Code's own local tools (like its Bash tool) run on your machine — they can't see the remote server. Only the
shell_executeMCP tool actually runs commands on the target server, via the intercept engine there.
Once connected, just talk to Claude naturally:
List running services
Show disk usage
Restart nginx
Install htop
Read /etc/nginx/nginx.conf
Show the last 50 lines of /var/log/syslog
Every command is evaluated against the rule set before execution:
| Outcome | What Claude sees | What you do |
|---|---|---|
| Allowed | Result returned immediately | Nothing — it ran |
| Pending | "Action is pending approval (ID: abc123)" | Approve in the Web UI |
| Blocked | "Blocked: [reason]" | Cannot be approved — rule must be changed |
Claude polls check_pending_action automatically while waiting for your approval.
- Open
http://YOUR_SERVER_IP:8000/ui/login - Log in with the admin credentials created during installation
- Go to Pending Actions
- Review the command and click Approve or Deny
- Claude receives the result within seconds
Go to History in the web UI to see every tool call — what ran, who called it, the risk level assigned, the outcome, and the full stdout/stderr.
| Role | Can do |
|---|---|
read_only |
View audit logs and pending actions only |
operator |
Run SAFE/CAUTION tools directly; DANGEROUS actions require UI approval |
admin |
Full access, including rule management |
In --stdio mode the server assumes it's embedded in a trusted local Claude Code session and grants admin with no auth.
Rules live in config/rules/custom_rules.yaml. This file is hot-reloaded every 30 seconds — no server restart needed. Custom rules with the same id as a default rule override it; lower priority numbers run first.
Example — always block a specific path from deletion:
rules:
- id: block_delete_var_www
name: "Protect /var/www from deletion"
enabled: true
tool: "file_delete"
conditions:
pattern: "/var/www"
risk: blocked
action: block
reason: "Web root is protected."
alert: true
priority: 10Example — allow a normally-dangerous command without approval:
rules:
- id: allow_restart_nginx
name: "Allow nginx restart without approval"
enabled: true
tool: "service_restart"
conditions:
pattern: "nginx"
risk: caution
action: allow
priority: 5pytestSet MCP_INTERCEPT__DRY_RUN_MODE=true to log every tool call through the intercept engine without actually executing anything — useful for testing rule coverage.
| Task | Command / URL |
|---|---|
| Start server | python main.py |
| Run as stdio | python main.py --stdio |
| Create web user | python scripts/create_user.py --username X --password Y --role admin |
| Generate API key | python scripts/generate_api_key.py --name X --permission admin |
| Web UI | http://SERVER:8000/ui/login |
| Health check | http://SERVER:8000/api/health |
| View logs (systemd) | journalctl -u mcp-server -f |
| Reload rules | Edit config/rules/custom_rules.yaml (auto-reloads in 30s) |
| Dry-run mode | Set MCP_INTERCEPT__DRY_RUN_MODE=true in .env |
| Install mcp-remote | npm install -g mcp-remote (needed for Claude Desktop) |
Open architecture.html in a browser — it renders four Mermaid diagrams via the Mermaid CDN (requires internet):
- Overall Architecture — the two connection modes side by side: stdio (local Claude Code CLI, direct admin access, no auth) vs. HTTP/SSE (remote Claude, Bearer token → auth middleware → MCP tools).
- Intercept Engine Decision Flow — the exact branching logic inside
engine.process(): rule match → risk classification → permission check → allow/block/confirm. - Human-in-the-Loop Sequence — the full numbered sequence for a DANGEROUS action, from
pending_idthrough UI approval to re-execution. - Permission × Risk Matrix — which role can execute each risk level.
This is a personal project, not a hardened security product. Review the default rules, run it in dry-run mode first, and don't expose it beyond a trusted network without HTTPS in front of it.