- Posted on
- • Artificial Intelligence
MCP Case Studies
- Author
-
-
- User
- linuxbash
- Posts by this author
- Posts by this author
-
MCP Case Studies: Bash-first automation that lets AI call your Linux tools
If you’ve ever copy-pasted a 300-line log into an AI assistant and thought “there has to be a better way,” you’re not alone. The Model Context Protocol (MCP) is emerging as a practical way to let AI systems call your tools directly—safely, with guardrails, and using the CLI you already know.
This post is a Bash-first guide to MCP case studies: realistic Linux scenarios where small, composable scripts do the heavy lifting and an MCP server simply exposes them as callable tools. You’ll get install steps, drop-in scripts, and battle-tested tips, so you can go from idea to proof-of-concept in an afternoon.
What problem does MCP solve?
Repeatability: Instead of pasting volatile terminal output, the model invokes the same script every time with explicit parameters.
Security and control: Tools run in your environment. You choose what’s callable, validate inputs, and log every call.
Composability: Your existing Bash, Python, or Go utilities become a single toolbelt the model can orchestrate.
Interop: MCP relies on a simple, transport-agnostic pattern (commonly JSON over stdio or a socket) so you can use any language and grow incrementally.
In other words: keep your Linux muscle memory, add a protocol to let AI ask for exactly what it needs, and no more.
Prerequisites: install the basics
These case studies use common command-line tools. Install the following using your distro’s package manager. You can always trim or add as your use case evolves.
Ubuntu/Debian (apt):
sudo apt update
sudo apt install -y \
python3 python3-pip python3-venv \
nodejs npm \
jq ripgrep fd-find bat \
curl traceroute dnsutils iproute2 git
# Optional: convenience alias for fd on Debian/Ubuntu (binary is "fdfind")
if ! command -v fd >/dev/null 2>&1 && command -v fdfind >/dev/null 2>&1; then
echo 'alias fd=fdfind' | sudo tee -a /etc/bash.bashrc
source /etc/bash.bashrc
fi
Fedora/RHEL/CentOS (dnf):
sudo dnf install -y \
python3 python3-pip \
nodejs npm \
jq ripgrep fd-find bat \
curl traceroute bind-utils iproute git
# Note: on RHEL, you may need to enable EPEL for ripgrep/fd/bat.
openSUSE/SLE (zypper):
sudo zypper refresh
sudo zypper install -y \
python3 python3-pip \
nodejs npm \
jq ripgrep fd bat \
curl traceroute bind-utils iproute2 git
Why Python and Node? Many MCP servers are written in Python or TypeScript. Even if you stay 100% Bash, these runtimes make it easy to add a thin adapter later.
Case study 1: On-call log triage (grep the right lines, fast)
When incidents strike, context matters. Instead of dumping logs, expose a controlled “grep logs” tool the model can call with a pattern, path, and time window.
Drop-in script: loggrep.sh
#!/usr/bin/env bash
set -Eeuo pipefail
# Dependencies: ripgrep (rg), jq, optionally systemd's journalctl
# Purpose: search files or journal for a regex and return structured results.
usage() {
cat <<'EOF'
Usage:
loggrep.sh file <pattern> <path> [--icase] [--max N]
loggrep.sh journal <pattern> [--unit UNIT] [--since "TIME"] [--until "TIME"] [--icase] [--max N]
Examples:
loggrep.sh file "ERROR|WARN" /var/log/nginx/access.log --max 100
loggrep.sh journal "oom-kill" --unit kubelet --since "1 hour ago" --icase
EOF
}
[[ $# -lt 2 ]] && { usage; exit 1; }
mode="$1"; shift
pattern="$1"; shift
icase_flag=""
max_count=""
unit=""
since=""
until=""
while [[ $# -gt 0 ]]; do
case "$1" in
--icase) icase_flag="-i";;
--max) shift; max_count="$1";;
--unit) shift; unit="$1";;
--since) shift; since="$1";;
--until) shift; until="$1";;
*) arg_rest+=("$1");;
esac
shift || true
done
jq_safe() { jq -R -s .; }
if [[ "$mode" == "file" ]]; then
path="${arg_rest[0]:-}"
[[ -z "${path}" || -z "${pattern}" ]] && { usage; exit 2; }
[[ -r "$path" ]] || { echo "Path not readable: $path" >&2; exit 3; }
# ripgrep JSON output -> condensed JSON array
rg ${icase_flag} --json --line-number --no-messages --max-count "${max_count:-0}" -- "$pattern" "$path" \
| jq -rs '
[ .[] | select(.type=="match") |
{ file: .data.path.text,
line: .data.lines.text,
line_number: .data.line_number } ]'
elif [[ "$mode" == "journal" ]]; then
command -v journalctl >/dev/null 2>&1 || { echo "journalctl not found" >&2; exit 4; }
jargs=( -o short-iso )
[[ -n "$unit" ]] && jargs+=( -u "$unit" )
[[ -n "$since" ]] && jargs+=( --since "$since" )
[[ -n "$until" ]] && jargs+=( --until "$until" )
# Stream journal lines, filter with rg, cap results
journalctl "${jargs[@]}" \
| rg ${icase_flag} --line-number --no-messages --max-count "${max_count:-0}" -- "$pattern" \
| awk -F: '{ln=$1; sub(/^[^ ]+ /,"",$0); print "{\"line_number\":"ln",\"line\":" "\"" $0 "\"" "}"}' \
| jq -s '.'
else
usage; exit 5
fi
Why this works well with MCP
Deterministic: clearly defined inputs and a stable output shape (JSON).
Narrow scope: the tool can only read specific logs you allow.
Extensible: add whitelists or path allowlists without changing the protocol.
Case study 2: Package compliance (pending updates and security advisories)
Expose a read-only “what’s pending?” tool that enumerates updates and security advisories in a distro-aware way.
Drop-in script: pkg_audit.sh
#!/usr/bin/env bash
set -Eeuo pipefail
# Reports pending package updates and security advisories across apt/dnf/zypper.
# Output: simple JSON with counts and summaries.
require() { command -v "$1" >/dev/null 2>&1 || { echo "Missing: $1" >&2; exit 1; }; }
to_json_string() { jq -Rn --arg s "$1" '$s'; }
manager=""
if command -v apt-get >/dev/null 2>&1; then
manager="apt"
elif command -v dnf >/dev/null 2>&1; then
manager="dnf"
elif command -v zypper >/dev/null 2>&1; then
manager="zypper"
else
echo '{"error":"unsupported package manager"}'
exit 2
fi
updates=""
sec=""
case "$manager" in
apt)
sudo apt-get -qq update || true
# Upgradable list
updates="$(apt list --upgradable 2>/dev/null | awk 'NR>1{print $0}')"
# Heuristic: security pocket often tagged with -security
sec="$(apt-get -s upgrade | awk '/^Inst/ && /-security/ {print $0}')"
;;
dnf)
updates="$(dnf -q check-update || true)"
sec="$(dnf -q updateinfo list security all || true)"
;;
zypper)
updates="$(zypper -q lu || true)"
sec="$(zypper -q lp --category security || true)"
;;
esac
# Count lines with content
u_count=$(printf "%s\n" "$updates" | sed '/^\s*$/d' | wc -l | tr -d ' ')
s_count=$(printf "%s\n" "$sec" | sed '/^\s*$/d' | wc -l | tr -d ' ')
# Emit minimal JSON
jq -n --arg mgr "$manager" \
--arg u "$updates" --arg s "$sec" \
--argjson uc "$u_count" --argjson sc "$s_count" \
'{
manager: $mgr,
updates_count: $uc,
security_count: $sc,
updates_raw: $u,
security_raw: $s
}'
Why this works well with MCP
Read-only by default; easy to audit.
The model can ask “are there security patches pending?” and get a yes/no plus details.
You can add allowlisted actions later (e.g., “preview only” vs “apply now” guarded by policy).
Case study 3: Network diagnostics on demand
Pack connectivity checks into a single, parameterized tool the model can run when troubleshooting.
Drop-in script: netdiag.sh
#!/usr/bin/env bash
set -Eeuo pipefail
# Multipurpose network diagnostics
# Usage:
# netdiag.sh ping <host> [count]
# netdiag.sh trace <host>
# netdiag.sh ports [--listening] [--numeric]
# netdiag.sh dns <name> [type=A|AAAA|MX|TXT]
usage() {
cat <<'EOF'
Examples:
netdiag.sh ping example.com 4
netdiag.sh trace 1.1.1.1
netdiag.sh ports --listening --numeric
netdiag.sh dns example.com A
EOF
}
[[ $# -lt 1 ]] && { usage; exit 1; }
cmd="$1"; shift
case "$cmd" in
ping)
host="${1:-}"; count="${2:-4}"
[[ -z "$host" ]] && { usage; exit 2; }
timeout 10 ping -c "$count" -W 2 "$host" || true
;;
trace)
host="${1:-}"; [[ -z "$host" ]] && { usage; exit 3; }
if command -v traceroute >/dev/null 2>&1; then
timeout 20 traceroute -n "$host" || true
elif command -v tracepath >/dev/null 2>&1; then
timeout 20 tracepath -n "$host" || true
else
echo "traceroute/tracepath not installed" >&2; exit 4
fi
;;
ports)
list_flag=""; num_flag=""
while [[ $# -gt 0 ]]; do
case "$1" in
--listening) list_flag="-l";;
--numeric) num_flag="-n";;
*) ;;
esac
shift
done
if command -v ss >/dev/null 2>&1; then
ss -tulp${list_flag:+l} ${num_flag:+-n} || true
else
echo "ss (iproute2) not available" >&2; exit 5
fi
;;
dns)
name="${1:-}"; rr="${2:-A}"
[[ -z "$name" ]] && { usage; exit 6; }
if command -v dig >/dev/null 2>&1; then
dig +nocomments +noquestion +nocmd "$name" "$rr"
else
echo "dig not installed (dnsutils/bind-utils)" >&2; exit 7
fi
;;
*)
usage; exit 8;;
esac
Why this works well with MCP
One tool entry point, multiple safe sub-commands.
Timeouts avoid hanging sessions.
Easy for a model to chain: ping -> trace -> inspect open ports.
Case study 4: Local knowledge search (man pages, READMEs, runbooks)
Expose “read-only knowledge” so the model can cite facts from your environment, not the public internet.
Drop-in script: kb_search.sh
#!/usr/bin/env bash
set -Eeuo pipefail
# Grep a documentation root for patterns and return context lines.
# Usage: kb_search.sh <doc_root> <query> [--icase] [--before N] [--after N] [--max-count N]
[[ $# -lt 2 ]] && { echo "Usage: kb_search.sh <doc_root> <query> [...]" >&2; exit 1; }
doc_root="$1"; shift
query="$1"; shift
icase=""
before=1
after=1
maxc=50
while [[ $# -gt 0 ]]; do
case "$1" in
--icase) icase="-i";;
--before) shift; before="$1";;
--after) shift; after="$1";;
--max-count) shift; maxc="$1";;
esac
shift || true
done
[[ -d "$doc_root" ]] || { echo "Not a directory: $doc_root" >&2; exit 2; }
# Print filename, line, and context in a simple, model-friendly format
rg ${icase} --no-heading -n -C "${before},${after}" --max-count "${maxc}" -- "$query" "$doc_root" \
| sed 's/\x1b\[[0-9;]*m//g'
Why this works well with MCP
Curated scope: point it to manpage exports, runbooks, or config directories.
Model can provide citations by filename and line number.
Easy to serialize if you prefer JSON later.
How these scripts fit into an MCP server
MCP servers expose discrete “tools.” In practice, that means your server process:
Receives a tool name and parameters from the MCP client
Validates inputs and maps the call to a local action
Executes your script with a whitelist of flags
Returns structured output (JSON or plain text) and exit status
Logs the call for auditing
A pragmatic path for Bash-first teams: 1) Keep tools as scripts like the ones above. 2) Wrap them with a small adapter in your preferred language that speaks MCP and shells out to the scripts. 3) Constrain and sanitize everything: allowed commands, directories, and maximum durations.
Example: a very thin JSON-in/JSON-out adapter you can evolve into a full MCP server later.
tool_adapter.py (not a full MCP implementation, but illustrates the pattern)
#!/usr/bin/env python3
import json, subprocess, sys, shlex
# Read a single JSON object from stdin:
# {"tool":"loggrep","args":{"mode":"file","pattern":"ERROR","path":"/var/log/syslog","max":100}}
req = json.loads(sys.stdin.read())
tool = req.get("tool")
args = req.get("args", {})
def run(cmd, timeout=20):
p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return {"rc": p.returncode, "stdout": p.stdout, "stderr": p.stderr}
if tool == "loggrep":
mode = args.get("mode","file")
pattern = args.get("pattern")
path = args.get("path","")
flags = []
if args.get("icase"): flags.append("--icase")
if (m := args.get("max")): flags += ["--max", str(m)]
cmd = ["./loggrep.sh", mode, pattern] + ([path] if path else []) + flags
res = run(cmd, timeout=30)
elif tool == "pkg_audit":
res = run(["./pkg_audit.sh"], timeout=60)
elif tool == "netdiag":
sub = args.get("sub","ping")
rest = args.get("rest",[])
cmd = ["./netdiag.sh", sub] + [str(x) for x in rest]
res = run(cmd, timeout=30)
elif tool == "kb_search":
doc_root = args.get("doc_root",".")
query = args.get("query")
flags = []
if args.get("icase"): flags.append("--icase")
if (b := args.get("before")): flags += ["--before", str(b)]
if (a := args.get("after")): flags += ["--after", str(a)]
if (m := args.get("max_count")): flags += ["--max-count", str(m)]
cmd = ["./kb_search.sh", doc_root, query] + flags
res = run(cmd, timeout=30)
else:
res = {"rc": 127, "stdout": "", "stderr": f"unknown tool: {tool}"}
print(json.dumps(res))
You can now test your adapter by piping JSON:
echo '{"tool":"pkg_audit","args":{}}' | python3 tool_adapter.py | jq
From here, swap the adapter’s I/O for your MCP SDK’s server primitives and map SDK tool handlers to these scripts. The contract and validation stay the same.
Tip: whichever SDK you use, keep stdio small and stable. Return JSON for structured tools (loggrep, pkg_audit) and compact text for exploratory tools (netdiag). Add allowlists, timeouts, and resource limits early.
Actionable checklist
Start small: pick one high-signal tool (e.g., loggrep) and make its output model-friendly (JSON, capped length).
Add guardrails: input validation, path allowlists, and timeouts in scripts and adapter.
Standardize outputs: consistent JSON shapes let the model chain tools without brittle parsing.
Log everything: append tool name, args, duration, and exit code to a file for audits.
Iterate with real incidents: record prompts, tweak flags, add missing parameters and context.
Conclusion and next steps
MCP doesn’t require you to reinvent your stack. It’s a thin layer that lets AI call the tools you already trust—on your terms. Start with the four case studies above, wire them into a minimal adapter, and you’ll immediately feel the difference during incidents and day‑to‑day ops.
Next steps:
Drop these scripts into a dedicated repo and add tests.
Choose an MCP SDK in your preferred language and map each tool to a handler.
Package the server as a systemd service with a locked-down service user.
Add one “write” capability behind an explicit policy gate (e.g., “roll logs” or “restart safe service”) once you’re confident in the read-only flow.
Have a cool Bash-friendly MCP use case or a twist on these scripts? Share it with the community—your future on-call self will thank you.