- Posted on
- • Artificial Intelligence
MCP for Bash Automation
- Author
-
-
- User
- linuxbash
- Posts by this author
- Posts by this author
-
MCP for Bash Automation: Turn Shell Scripts into Safe, Discoverable Tools
If you’ve ever handed a coworker (or your future self) a pile of Bash scripts with a README and some “be careful” notes, you know the pain. Scripts drift, options change, and the people or systems that need them don’t always use them safely.
Model Context Protocol (MCP) offers a new pattern: expose your Bash automations as well-defined, permissioned “tools” with typed parameters and descriptions that an MCP-aware client (and even AI assistants) can discover and call safely. Think: one interface to your estate of scripts—documented, versioned, and controlled.
This post shows how to wrap Bash tasks behind a tiny MCP server, wire it to an MCP client, and apply production-minded guardrails. You’ll leave with a working example, practices to keep things safe, and installation steps for common Linux distros.
What is MCP, and why use it for Bash?
MCP (Model Context Protocol) is a simple, open protocol that lets “clients” (like AI assistants or developer tools) discover and call “servers” that expose tools, resources, or prompts. For Bash automation, this means:
Discoverability: Your scripts and operations become self-documenting tools with names, parameter schemas, and help text.
Safety: You can whitelist commands, limit parameters, set timeouts, and centralize policy instead of letting users freestyle bash.
Portability: The same server runs anywhere you have Bash, so your automation interface is consistent across machines and teams.
AI-native: MCP-aware assistants can invoke your tools directly and include results in context, with your policies intact.
Prerequisites and installation
We’ll build a minimal MCP server in Python that shells out to Bash for real work. You’ll need:
Python 3.10+ (3.11+ recommended)
pip or pipx
jq (optional, handy for JSON debugging)
Bash (already on your system)
Install the basics for your distro:
Debian/Ubuntu (apt):
sudo apt update sudo apt install -y python3 python3-venv python3-pip pipx jqIf pipx isn’t available on your release:
python3 -m pip install --user pipx python3 -m pipx ensurepathFedora (dnf):
sudo dnf install -y python3 python3-virtualenv python3-pip pipx jqopenSUSE (zypper):
sudo zypper refresh sudo zypper install -y python3 python3-virtualenv python3-pip pipx jq
Install the MCP Python library globally via pipx (recommended) or in a virtualenv:
With pipx:
pipx install mcpOr with venv:
python3 -m venv .venv . .venv/bin/activate pip install mcp
A minimal “Bash Ops” MCP server
The server below exposes safe, parameterized tools that run underlying Bash/system commands. It includes:
sys_info: OS and kernel info (read-only)
tail_log: Tail a log file from an allowed directory
service_action: systemctl status/start/stop/restart (restrict mutating actions unless root)
backup_dir: Create a tar.gz archive from a directory (with path checks)
Create file mcp_bash_ops.py:
#!/usr/bin/env python3
import asyncio
import os
import shlex
import subprocess
from pathlib import Path
from typing import Optional, Literal
from mcp.server import Server
from mcp.server.stdio import StdioServerTransport
server = Server("bash-ops")
ALLOWED_LOG_DIRS = [Path("/var/log")]
ALLOWED_BACKUP_BASE = [Path("/home"), Path("/var"), Path("/etc")]
DEFAULT_TIMEOUT = 30 # seconds
def run_cmd(cmd: list[str], timeout: int = DEFAULT_TIMEOUT) -> tuple[int, str, str]:
proc = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=timeout,
check=False,
env={**os.environ, "LC_ALL": "C", "LANG": "C"},
)
return proc.returncode, proc.stdout.strip(), proc.stderr.strip()
def is_subpath(path: Path, bases: list[Path]) -> bool:
try:
p = path.resolve()
except Exception:
return False
for base in bases:
try:
if p == base.resolve() or p.is_relative_to(base.resolve()): # Python 3.9+: use workaround if needed
return True
except AttributeError:
# For Python <3.9, emulate is_relative_to
try:
p.relative_to(base.resolve())
return True
except Exception:
pass
return False
@server.tool(
name="sys_info",
description="Return basic OS and kernel information (read-only).",
)
async def sys_info() -> dict:
code, uname_out, _ = run_cmd(["uname", "-a"])
os_release = {}
try:
for line in Path("/etc/os-release").read_text().splitlines():
if "=" in line:
k, v = line.split("=", 1)
os_release[k] = v.strip().strip('"')
except Exception:
pass
return {
"uname": uname_out if code == 0 else "",
"os": os_release.get("PRETTY_NAME") or os_release.get("NAME") or "unknown",
"kernel": uname_out.split()[2] if uname_out else "unknown",
}
@server.tool(
name="tail_log",
description="Tail a log file from allowed directories (default /var/log).",
)
async def tail_log(
path: str,
lines: int = 100,
) -> dict:
log_path = Path(path)
if not is_subpath(log_path, ALLOWED_LOG_DIRS):
return {"error": f"path not allowed: {log_path}"}
if not log_path.exists() or not log_path.is_file():
return {"error": f"file not found: {log_path}"}
code, out, err = run_cmd(["tail", f"-n{int(lines)}", str(log_path)])
return {"ok": code == 0, "stdout": out, "stderr": err}
@server.tool(
name="service_action",
description="systemctl status/start/stop/restart a service. Mutating actions may require root.",
)
async def service_action(
name: str,
action: Literal["status", "start", "stop", "restart"] = "status",
) -> dict:
if os.geteuid() != 0 and action in {"start", "stop", "restart"}:
return {"error": "must be root to perform mutating actions"}
code, out, err = run_cmd(["systemctl", action, name])
return {"ok": code == 0, "stdout": out, "stderr": err}
@server.tool(
name="backup_dir",
description="Create a .tar.gz archive of a directory under allowed bases (home, /etc, /var).",
)
async def backup_dir(
src_dir: str,
dest_tar_gz: Optional[str] = None,
) -> dict:
src = Path(src_dir)
if not src.exists() or not src.is_dir():
return {"error": f"source not found or not a directory: {src}"}
if not is_subpath(src, ALLOWED_BACKUP_BASE):
return {"error": f"source outside allowed bases: {src}"}
if dest_tar_gz is None:
dest_tar_gz = f"/tmp/{src.name}.tar.gz"
dest = Path(dest_tar_gz).resolve()
# Prevent writing archives into disallowed system locations except /tmp or under user home
if not is_subpath(dest, [Path("/tmp"), Path.home()]):
return {"error": f"destination not allowed: {dest}"}
# tar -C src .
cmd = ["tar", "-czf", str(dest), "-C", str(src), "."]
code, out, err = run_cmd(cmd, timeout=120)
return {"ok": code == 0, "archive": str(dest), "stderr": err or out}
async def main():
transport = StdioServerTransport()
await server.run(transport)
if __name__ == "__main__":
asyncio.run(main())
Make it executable:
chmod +x mcp_bash_ops.py
Notes:
The server communicates over stdio; most MCP clients spawn it with command + args.
We restrict paths and actions; tune ALLOWED_* as needed.
Mutating service actions require root (sudo) by design.
Wire it into an MCP client
Any MCP-aware client can discover and call your tools once configured. Two popular Linux-friendly options:
1) Cursor editor (settings.json)
Open Cursor settings (JSON) and add:
"mcpServers": { "bash-ops": { "command": "/usr/bin/python3", "args": ["/absolute/path/to/mcp_bash_ops.py"] } }Restart Cursor. In an AI chat, ask it to “tail the last 50 lines of /var/log/syslog using the bash-ops tool,” and it should discover and call tail_log.
2) VS Code + Cline (extension) with MCP
Install “Cline” from the Marketplace.
In Cline settings, add a server entry pointing at your Python command and script path (Cline exposes a “MCP Servers” section).
Start a Cline session; it should list bash-ops tools and be able to call them.
If you’re using an MCP client that reads a JSON config, it usually needs:
name: “bash-ops”
command: python3
args: ["/path/to/mcp_bash_ops.py"]
optional env: {}
Tip: Always use absolute paths. If your Python is inside a venv, point to .venv/bin/python and ensure the mcp package is installed in that environment.
Try these real-world tasks
Investigate a flaky service:
- “Call service_action with name=nginx and action=status, then summarize errors.”
Triage logs:
- “Use tail_log on /var/log/auth.log with lines=200 and surface suspicious entries.”
Quick backup before a risky change:
- “Run backup_dir on /etc to /tmp/etc-$(date +%F).tar.gz, then verify the archive exists.”
Environment report for tickets:
- “Call sys_info and format the output for a bug report.”
Under the hood, the client calls your exposed tools, and your server gates what actually runs.
4 production-minded practices
Whitelist and validate aggressively
- Constrain paths to known directories. Validate enum-like parameters. Prefer arguments over concatenated shell strings. Use timeouts.
Separate read-only from mutating tools
- Make status/info tools usable by non-root. Require root (or a specific service account) for actions like restart/stop. Consider sudoers rules that allow only specific commands.
Make outputs machine- and human-friendly
- Return structured JSON fields (ok, stdout, stderr, archive, etc.). Clients and log pipelines love predictable shapes.
Log and observe
- Add simple logging (who called what, when, with which params, and exit codes). Ship logs to your standard destination (journald, syslog, or a file with logrotate).
Troubleshooting tips
Permission denied on service_action:
- You’re likely not root. Use sudo to start the MCP server with elevated rights or keep mutating actions separated in a privileged server.
systemctl not found or not running:
- On containers or minimal distros, systemd may be absent. Swap systemctl for service or expose container-specific tools (e.g., docker).
SELinux/AppArmor blocks:
- Check audit logs and adjust policy or run in permissive mode for testing only.
PATH and environments differ from your shell:
- MCP servers run with a minimal environment. Specify full paths (e.g., /usr/bin/tar) or set PATH in env when launching the server.
Bonus: Local manual testing without an MCP UI
If you want to sanity-check the Python code paths quickly, you can invoke the tool functions directly by importing them, or write a tiny Python harness that calls them. For end-to-end MCP message testing, prefer an MCP-aware client (Cursor/Cline) since it handles the protocol handshake and discovery for you.
jq can still be handy to pretty-print structured outputs you return in the tool responses if you log or echo them somewhere.
Install jq if you skipped it earlier:
apt:
sudo apt update sudo apt install -y jqdnf:
sudo dnf install -y jqzypper:
sudo zypper install -y jq
Where MCP shines for Bash
Standardized, safe interfaces to powerful scripts
Clear, typed parameters with discoverable docs
The same automation callable by humans, CI/CD, or AI assistants
Easier audits and change management because tooling is centralized
Conclusion and next steps
With a small Python MCP server, you can turn decades of Bash know-how into safe, discoverable tools your team (and assistants) can rely on. Start by exposing read-only diagnostics, then layer in carefully restricted mutating actions.
Your next steps:
Install the prerequisites (python3, pip/pipx, mcp) with apt/dnf/zypper.
Save and run mcp_bash_ops.py, then register it in your MCP client (Cursor or Cline).
Incrementally add your own tools: disk usage reports, nginx reloads, database backups—with strict validation and timeouts.
Share the config with your team.
Have an existing script you want to wrap with MCP? Paste its purpose and flags, and I’ll help you model a safe tool interface for it.