Posted on
Artificial Intelligence

MCP Servers with Artificial Intelligence

Author
  • User
    linuxbash
    Posts by this author
    Posts by this author

MCP Servers with Artificial Intelligence: Give Your Bash Superpowers

If you’ve ever thought “I wish my shell scripts could ask an AI to do the boring parts safely,” you’re ready for MCP. The Model Context Protocol (MCP) lets AI assistants call well-defined, auditable tools you expose as a small “server” process. Instead of giving an AI your entire machine, you give it guarded entry points like “read these logs,” “run this safe command,” or “query this database.” That’s the power of MCP servers on Linux.

This article explains why MCP matters, then walks you through building a minimal MCP-style server in Python that exposes safe Unix tools over JSON-RPC via stdin/stdout—perfect for integration with modern AI assistants or for local experimentation.

Why MCP on Linux is worth your time

  • Clear boundaries: You define exactly which tools and resources an AI can use—no more, no less.

  • Reproducible and debuggable: Everything is an explicit request/response you can log and audit.

  • Least privilege by design: Wrap sensitive operations behind sanitized, allowlisted calls.

  • Portable: It’s just a process that speaks JSON-RPC over stdio or sockets; easy to version, containerize, and ship.

  • Extensible: Start with simple commands (uname, grep), grow into org-wide automations (ticketing, CI, fleet queries).

What we’ll build

  • A tiny MCP-style server that supports two tools:

    • system_info: returns kernel and OS basics via uname.
    • grep_logs: safely searches common system logs with an allowlist.
  • CLI-only workflow using Bash and Python, with install steps for apt, dnf, and zypper.

  • How to run, test, and harden it.

Note: We’ll adhere to the JSON-RPC 2.0 pattern commonly used by MCP clients. Actual MCP clients spawn servers and talk over stdio; that’s exactly what our script does.


1) Prerequisites and installation

We’ll use Python 3, a virtual environment, and jq for pretty-printing JSON. Choose the commands for your distro.

Debian/Ubuntu (apt):

sudo apt update
sudo apt install -y python3 python3-venv python3-pip jq

Fedora/RHEL/CentOS Stream (dnf):

sudo dnf install -y python3 python3-pip python3-virtualenv jq

openSUSE Leap/Tumbleweed (zypper):

sudo zypper refresh
sudo zypper install -y python3 python3-pip python3-virtualenv jq

Set up a project directory and virtual environment:

mkdir -p ~/mcp-demo && cd ~/mcp-demo
python3 -m venv .venv
. .venv/bin/activate

No extra Python packages are required for this minimal server.


2) Scaffold a minimal MCP-style server

Create server.py with the code below. It implements two JSON-RPC methods:

  • tools/list: enumerate available tools (with simple input schemas).

  • tools/call: run a requested tool with validated arguments.

#!/usr/bin/env python3
import json
import os
import shlex
import subprocess
import sys
from typing import Any, Dict

JSONRPC = "2.0"

ALLOWED_LOG_FILES = [
    "/var/log/syslog",    # Debian/Ubuntu
    "/var/log/messages",  # Fedora/RHEL/openSUSE
]

def first_existing(paths):
    for p in paths:
        if os.path.isfile(p):
            return p
    return None

def resp_ok(id_, result):
    return {"jsonrpc": JSONRPC, "id": id_, "result": result}

def resp_err(id_, code, message, data=None):
    err = {"code": code, "message": message}
    if data is not None:
        err["data"] = data
    return {"jsonrpc": JSONRPC, "id": id_, "error": err}

def list_tools():
    return {
        "tools": [
            {
                "name": "system_info",
                "description": "Return basic system info (uname -a).",
                "input_schema": {
                    "type": "object",
                    "properties": {},
                },
            },
            {
                "name": "grep_logs",
                "description": "Search common system logs for a case-insensitive pattern. Returns up to 200 matches with line numbers.",
                "input_schema": {
                    "type": "object",
                    "properties": {
                        "pattern": {"type": "string", "description": "Regex or plain text to grep"},
                        "path": {"type": "string", "description": "Optional log path; must be allowlisted"}
                    },
                    "required": ["pattern"]
                },
            },
        ]
    }

def tool_system_info(_args: Dict[str, Any]) -> Dict[str, Any]:
    cp = subprocess.run(["uname", "-a"], check=False, capture_output=True, text=True)
    return {"stdout": cp.stdout.strip(), "stderr": cp.stderr.strip(), "returncode": cp.returncode}

def sanitize_pattern(pat: str) -> str:
    # Basic guardrails; reject very long or control-laden patterns.
    if not isinstance(pat, str):
        raise ValueError("pattern must be a string")
    if len(pat) > 256:
        raise ValueError("pattern too long")
    # Disallow NUL and some control chars
    for bad in ["\x00", "\r"]:
        if bad in pat:
            raise ValueError("invalid characters in pattern")
    return pat

def tool_grep_logs(args: Dict[str, Any]) -> Dict[str, Any]:
    if "pattern" not in args:
        raise ValueError("missing required argument: pattern")
    pattern = sanitize_pattern(args["pattern"])

    path = args.get("path") or first_existing(ALLOWED_LOG_FILES)
    if path not in ALLOWED_LOG_FILES:
        # If a user-supplied path matches allowlist strictly, permit it; otherwise deny.
        if path is None or path not in ALLOWED_LOG_FILES:
            raise ValueError("log path not allowed")

    # Use grep safely without invoking a shell; cap matches.
    cmd = ["grep", "-i", "-E", pattern, path, "-n", "-m", "200"]
    cp = subprocess.run(cmd, check=False, capture_output=True, text=True)
    # Truncate extremely large outputs defensively
    out = cp.stdout
    if len(out) > 64_000:
        out = out[:64_000] + "\n...[truncated]"

    return {"stdout": out, "stderr": cp.stderr.strip(), "returncode": cp.returncode, "path": path}

def handle_call(params: Dict[str, Any]) -> Dict[str, Any]:
    name = params.get("name")
    arguments = params.get("arguments", {}) or {}

    if name == "system_info":
        return tool_system_info(arguments)
    elif name == "grep_logs":
        return tool_grep_logs(arguments)
    else:
        raise ValueError(f"unknown tool: {name}")

def main():
    for line in sys.stdin:
        line = line.strip()
        if not line:
            continue
        try:
            req = json.loads(line)
            method = req.get("method")
            id_ = req.get("id")
            if method == "tools/list":
                result = list_tools()
                sys.stdout.write(json.dumps(resp_ok(id_, result)) + "\n")
                sys.stdout.flush()
            elif method == "tools/call":
                result = handle_call(req.get("params", {}))
                sys.stdout.write(json.dumps(resp_ok(id_, result)) + "\n")
                sys.stdout.flush()
            else:
                sys.stdout.write(json.dumps(resp_err(id_, -32601, f"Method not found: {method}")) + "\n")
                sys.stdout.flush()
        except Exception as e:
            # Ensure we always return a JSON-RPC error envelope
            try:
                id_ = req.get("id") if 'req' in locals() else None
            except Exception:
                id_ = None
            sys.stdout.write(json.dumps(resp_err(id_, -32000, "Server error", {"detail": str(e)})) + "\n")
            sys.stdout.flush()

if __name__ == "__main__":
    main()

Make it executable:

chmod +x server.py

3) Try it out from Bash

Because this server reads JSON from stdin and writes JSON to stdout, we can test it directly.

List tools:

printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \
| ./server.py | jq

Call system_info:

printf '%s\n' \
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"system_info","arguments":{}}}' \
| ./server.py | jq

Grep logs (pattern = ssh):

printf '%s\n' \
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"grep_logs","arguments":{"pattern":"ssh"}}}' \
| ./server.py | jq -r '.result.stdout' | head -n 20

If your distro uses a different log file, you can supply a path explicitly (must be allowlisted):

printf '%s\n' \
'{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"grep_logs","arguments":{"pattern":"error","path":"/var/log/messages"}}}' \
| ./server.py | jq -r '.result.stdout' | sed -n '1,20p'

Tip: In “real” MCP use, an AI client spawns this process and communicates over stdio using these same methods. Your CLI tests prove the contract works.


4) Harden and run it responsibly

  • Least privilege: create a dedicated user so the server can only read what it must.
sudo useradd --system --no-create-home --shell /usr/sbin/nologin mcp
sudo install -m 0755 server.py /usr/local/bin/mcp-demo
sudo chown root:root /usr/local/bin/mcp-demo
  • Consider log access: journal-based systems may require additional permissions or a different tool wrapper to read via journalctl instead of files.

  • Resource limits: if you expect heavy queries, add timeouts or cap output sizes (we already cap to 64 KB and 200 matches).

  • Audit: pipe stdout/stderr through a logger if you wrap this in a supervising process or client.

If you want a user-level systemd entry to make the binary easy to reference (the AI client will still spawn it on demand), you only need the installed executable. A background daemon is not required because stdio is the transport; your AI client starts/stops the server as needed.


5) Real-world patterns you can build next

  • SRE log triage: expose tools to fetch the last N lines of critical logs, grep by incident ID, and summarize error frequencies.

  • Service health probes: wrap curl with strict allowlists to hit internal status endpoints and parse JSON.

  • Git operations: create tools to list open PRs, diff files, or check commit messages in a repo directory that’s safe to expose.

  • SQL read-only analytics: present a parameterized, whitelisted set of queries against a replica; return CSV/JSON with row caps and timeouts.

  • Ticketing/ChatOps: expose “create_ticket” and “comment_ticket” tools with strict schemas to keep AI output structured and traceable.

Each tool should:

  • Validate inputs strictly (length, format, enums).

  • Avoid shell=True; always pass argv arrays to subprocess.

  • Limit output and execution time.

  • Log requests/responses for audits (mind sensitive data).


Troubleshooting

  • No output? Ensure each JSON request is on a single line and the server flushes stdout. Our code flushes after every response.

  • Permission denied on logs? Try an allowlisted path that exists on your distro. Debian/Ubuntu: /var/log/syslog; Fedora/RHEL/openSUSE: /var/log/messages.

  • jq not found? Install it as shown above with apt/dnf/zypper.


Conclusion and next steps

You just built a minimal MCP-style server that exposes safe, auditable Unix powers to an AI—without handing over your entire shell. From here:

  • Add your own tools with strict input schemas.

  • Package this in a repo with tests and CI.

  • Wire it into an AI assistant that supports MCP over stdio and start automating safely.

Your Bash skills + MCP’s guardrails = reliable, repeatable, AI-augmented ops. Try extending the server with one more real tool today—like “tail_logs” or “service_status”—and see how quickly your workflows evolve.