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 jq
    

    If pipx isn’t available on your release:

    python3 -m pip install --user pipx
    python3 -m pipx ensurepath
    
  • Fedora (dnf):

    sudo dnf install -y python3 python3-virtualenv python3-pip pipx jq
    
  • openSUSE (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 mcp
    
  • Or 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 jq
    
  • dnf:

    sudo dnf install -y jq
    
  • zypper:

    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.