- Posted on
- • Artificial Intelligence
MCP Best Practices
- Author
-
-
- User
- linuxbash
- Posts by this author
- Posts by this author
-
MCP Best Practices for Bash: Turn Your Linux Scripts into Safe, Reliable Tools
If you’ve ever wished your tried‑and‑true Bash scripts could be safely used by higher‑level systems (like AI assistants, CI/CD agents, or chatops bots) without becoming a security or reliability headache, you’re not alone. That’s exactly where MCP (Model Context Protocol) shines: a simple way to expose capabilities via a predictable JSON‑RPC interface.
In this post, you’ll learn what MCP is, why it matters to Linux/Bash users, and a handful of best practices to build robust, auditable MCP tools—complete with install commands and a minimal Bash MCP server you can adapt today.
What is MCP and why should Bash users care?
MCP is a small, transport‑agnostic protocol based on JSON‑RPC 2.0. It lets you define “tools” (capabilities) that structured clients can call in a standardized way.
It turns ad‑hoc shell scripts into well‑described, versioned APIs with input validation and consistent error handling.
Benefits:
- Reproducibility: Defined inputs/outputs, deterministic behavior.
- Security: Enforce least privilege, restrict I/O, and timeboxes.
- Observability: Structured logs and clear failure modes.
- Portability: Works over stdio or sockets, easily containerized.
The result: your existing Unix toolbox becomes safer to automate and easier to trust.
Prerequisites: packages you’ll want installed
We’ll use a small set of utilities in the examples and recommendations below.
Core tooling: jq (JSON), curl (HTTP), socat (I/O piping)
Hygiene: shellcheck (lint), shfmt (format)
Optional sandbox: bubblewrap (bwrap)
Install them with your package manager:
Debian/Ubuntu (apt)
sudo apt update && sudo apt install -y jq curl socat shellcheck shfmt bubblewrapFedora/RHEL/CentOS Stream (dnf)
sudo dnf install -y jq curl socat ShellCheck shfmt bubblewrapopenSUSE/SLE (zypper)
sudo zypper refresh && sudo zypper install -y jq curl socat ShellCheck shfmt bubblewrap
Note: If shfmt isn’t available on your distro, you can skip it or install it via your language toolchain of choice later.
Best practices that pay off
Below are five battle‑tested habits that make Bash‑based MCP tools safer and easier to maintain.
1) Contract‑first tools: name, schema, version
Give each tool a stable name and a short description.
Define a minimal JSON “input schema” mentally or in docs (e.g., required fields, allowed values) even if you’re not using a full JSON Schema validator.
Keep tools idempotent where possible (same inputs → same outputs), and log side effects when they aren’t.
Version your server (and tools) so clients can reason about compatibility.
Example of a simple “run” call structure a client might send:
{"jsonrpc":"2.0","id":1,"method":"run",
"params":{"tool":"grep-file","args":{"file":"/etc/hosts","pattern":"localhost"}}}
Example of a successful result:
{"jsonrpc":"2.0","id":1,"result":{"stdout":"1:127.0.0.1 localhost\n"}}
Example of an error:
{"jsonrpc":"2.0","id":1,"error":{"code":400,"message":"missing args","data":{"required":["file","pattern"]}}}
2) Make I/O robust: strict modes, JSON framing, timeouts
Always enable strict Bash flags and traps to fail fast.
Treat stdin/stdout as structured frames (one JSON‑RPC object per line) and rely on jq for parsing and building JSON.
Use
timeoutto prevent runaway calls; do not rely on the client to bail out.Never eval untrusted input and always pass user inputs as arguments (
--) to avoid injection.
A minimal stdio MCP server in Bash you can adapt:
#!/usr/bin/env bash
set -Eeuo pipefail
log() { printf '%s\n' "$*" >&2; }
respond() { # args: id_json result_json
local id_json="$1" result_json="$2"
jq -cn --argjson id "$id_json" --argjson result "$result_json" \
'{jsonrpc:"2.0", id:$id, result:$result}'
}
respond_error() { # args: id_json code message [data_json]
local id_json="$1" code="$2" message="$3" data_json="${4:-null}"
jq -cn --argjson id "$id_json" --argjson code "$code" --arg message "$message" --argjson data "$data_json" \
'{jsonrpc:"2.0", id:$id, error:{code:$code, message:$message, data:$data}}'
}
handle_run() { # arg: params_json -> prints result_json OR error wrapper
local params_json="$1"
local tool file pattern
tool=$(jq -r '.tool // ""' <<<"$params_json")
case "$tool" in
"grep-file")
file=$(jq -r '.args.file // ""' <<<"$params_json")
pattern=$(jq -r '.args.pattern // ""' <<<"$params_json")
if [[ -z "$file" || -z "$pattern" ]]; then
jq -cn --argjson data '{"required":["file","pattern"]}' '{error:"missing args",data:$data}'
return 0
fi
# Constrain execution with a timeout. Avoid shell injection by using -- and quoted args.
if out=$(timeout 5s grep -n -- "$pattern" "$file" 2>&1); then
jq -cn --arg out "$out" '{stdout:$out}'
else
# Non-zero exit; surface as structured data
jq -cn --arg out "$out" '{stdout:$out, exit_code:1}'
fi
;;
*)
jq -cn --arg msg "unknown tool: $tool" '{error:$msg}'
;;
esac
}
# Main loop: one JSON-RPC object per line on stdin
while IFS= read -r line; do
[[ -z "${line// }" ]] && continue
# Extract JSON-RPC fields
id_json=$(jq -c '.id' <<<"$line" 2>/dev/null || echo 'null')
method=$(jq -r '.method // empty' <<<"$line" 2>/dev/null || echo '')
params_json=$(jq -c '.params // {}' <<<"$line" 2>/dev/null || echo '{}')
case "$method" in
"ping")
printf '%s\n' "$(respond "$id_json" '{"ok":true}')" || true
;;
"run")
result_or_err=$(handle_run "$params_json" || true)
if jq -e 'has("error")' >/dev/null 2>&1 <<<"$result_or_err"; then
msg=$(jq -r '.error' <<<"$result_or_err")
data=$(jq -c '.data // null' <<<"$result_or_err")
printf '%s\n' "$(respond_error "$id_json" 400 "$msg" "$data")" || true
else
printf '%s\n' "$(respond "$id_json" "$result_or_err")" || true
fi
;;
*)
printf '%s\n' "$(respond_error "$id_json" -32601 "Method not found")" || true
;;
esac
done
Try it:
chmod +x mcp-server.sh
# Ping
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"ping"}' | ./mcp-server.sh
# Run a safe grep
printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"run","params":{"tool":"grep-file","args":{"file":"/etc/hosts","pattern":"localhost"}}}' | ./mcp-server.sh
3) Enforce least privilege: sandbox and isolate
- Run tools with as little authority as possible. Techniques:
- Drop network when unnecessary.
- Mount read‑only file systems or chroot‑like views.
- Use a dedicated, unprivileged service user.
- Limit environment exposure; pass only what you need.
Using bubblewrap (simple, read‑only root, no network) around a command:
bwrap --ro-bind / / --proc /proc --unshare-net --dev /dev -- bash -lc 'grep -n -- "$PATTERN" "$FILE"'
Combine with timeout for defense‑in‑depth:
timeout 5s bwrap --ro-bind / / --proc /proc --unshare-net -- bash -lc 'grep -n -- "$PATTERN" "$FILE"'
Note: Bubblewrap is optional but powerful; ensure it’s available on your distro (see install commands above).
4) Log like an API: structured, minimal, and safe
Write logs to stderr so stdout remains a clean JSON channel for responses.
Include a correlation identifier (use the JSON‑RPC id).
Redact secrets. Be deliberate about what you log—avoid dumping full params.
Prefer one‑line JSON log records for easy ingestion:
log_json() { jq -cn --arg lvl "$1" --arg msg "$2" '{level:$lvl, msg:$msg, ts:(now|todate)}' >&2; }
5) Test, lint, and format to avoid “works on my machine”
Lint scripts:
shellcheck your-server.shFormat consistently:
shfmt -w your-server.shAdd smoke tests that pass known requests and assert responses:
got=$(printf '%s\n' '{"jsonrpc":"2.0","id":42,"method":"ping"}' | ./mcp-server.sh)
jq -e '.result.ok == true and .id == 42' <<<"$got" >/dev/null || { echo "Ping test failed"; exit 1; }
These checks catch regressions early and keep your protocol stable.
Real‑world pattern: safe “grep-file” tool
Intent: Let a client search a known file for a pattern, safely and predictably.
Guardrails you saw above:
- Input validation (require file and pattern).
- No command interpolation; always use
--and quoted args. - Timeout enforced; no unbounded calls.
- Structured success and error payloads for clients.
This pattern generalizes to many utilities: wc, sed (with restricted flags), log sampling, JSON transforms via jq, health checks, etc.—each wrapped with the same rules.
Conclusion and next steps
MCP gives your Bash toolbox a clean contract. Combine it with strict scripting, validation, isolation, and structured logging, and you get tools that are both powerful and safe to automate.
Your action plan: 1) Install the prerequisites (jq, curl, socat, shellcheck, shfmt, bubblewrap) using your package manager above. 2) Copy the minimal server script and add one tool you actually need today. 3) Add timeouts, sandboxing, and logs before you expose it to clients. 4) Lint and test: run shellcheck, shfmt, and a couple of request/response smoke tests.
Have a use case you want to harden under MCP? Try adapting the sample server to your environment, then iterate with the five best practices until it’s production‑ready.