Posted on
Artificial Intelligence

Build Your First MCP Server

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

Build Your First MCP Server on Linux (Bash-first Guide)

If you’ve ever wished your shell scripts and local tools could talk to your AI assistant as cleanly as an API, the Model Context Protocol (MCP) is your bridge. MCP lets you expose tools, resources, and prompts from any process (including a simple script) over a standardized protocol so AI clients can discover and call them safely.

In this guide, you’ll build your first MCP server on Linux, wrap a real-world Bash command as a callable tool, and run it reliably. You’ll get distro-specific install commands (apt, dnf, zypper) and end with a ready-to-wire server for any MCP-aware client.

Why MCP is worth your time

  • Interoperability by design: Any MCP-compatible client can discover and call your tools without custom plugins.

  • Security and control: Tools run inside a process you own. You decide what’s exposed and how it’s executed.

  • Fits your stack: MCP servers can be written in Python, Node.js, or anything that can speak JSON-RPC over stdio.

  • Great for Linux users: It’s trivial to wrap system utilities and Bash scripts (df, uptime, journalctl, curl, kubectl, etc.) as AI-callable tools.

What we’re building

  • A minimal MCP server that:

    • Exposes a “system uptime” tool.
    • Optionally wraps a Bash command.
    • Runs over stdio (the most common transport).
  • A systemd user service to keep it running.

  • Example client configuration snippet to register your server.

You can do this in Python or Node.js. Pick one path below.


1) Install prerequisites

Choose Python or Node.js. You can install both if you want to try each example.

  • Common basics
# apt (Debian/Ubuntu)
sudo apt update
sudo apt install -y git curl

# dnf (Fedora/RHEL/CentOS Stream)
sudo dnf install -y git curl

# zypper (openSUSE / SLE)
sudo zypper install -y git curl
  • Python 3 + venv + pip
# apt (Debian/Ubuntu)
sudo apt update
sudo apt install -y python3 python3-venv python3-pip

# dnf (Fedora/RHEL/CentOS Stream)
sudo dnf install -y python3 python3-pip

# zypper (openSUSE / SLE)
sudo zypper install -y python3 python3-pip
  • Node.js + npm (optional if you’re doing the Node path)
# apt (Debian/Ubuntu)
sudo apt update
sudo apt install -y nodejs npm

# dnf (Fedora/RHEL/CentOS Stream)
sudo dnf install -y nodejs npm

# zypper (openSUSE / SLE)
sudo zypper install -y nodejs npm

Tip: Distro Node.js can be older. If you need newer, consider nvm. But for a first server, distro packages usually suffice.


2) Build a minimal MCP server (Python path)

  • Create a project and virtual environment
mkdir -p ~/mcp-demo-python
cd ~/mcp-demo-python
python3 -m venv .venv
. .venv/bin/activate
pip install --upgrade pip
pip install mcp
  • Write a tiny server that exposes uptime and a Bash-powered tool

Create file server.py:

#!/usr/bin/env python3
import os
import subprocess
import anyio

from mcp.server import Server
from mcp.server.stdio import stdio_server

server = Server("demo-mcp-python")

@server.tool()  # No-arg tool
def uptime() -> str:
    "Return human-friendly system uptime"
    return subprocess.check_output(["uptime", "-p"], text=True).strip()

@server.tool()
def grep_log(pattern: str, log_path: str = "/var/log/syslog", max_lines: int = 50) -> str:
    """
    Grep the last N lines of a log for a pattern.
    Example: pattern="error", log_path="/var/log/syslog", max_lines=200
    """
    if max_lines < 1 or max_lines > 10000:
        raise ValueError("max_lines must be between 1 and 10000")
    # Tail then grep so we never scan unbounded logs
    tail_cmd = ["tail", "-n", str(max_lines), log_path]
    grep_cmd = ["grep", "-i", pattern]
    try:
        tail = subprocess.Popen(tail_cmd, stdout=subprocess.PIPE, text=True)
        out = subprocess.check_output(grep_cmd, stdin=tail.stdout, text=True)
        return out.strip() or "(no matches)"
    except subprocess.CalledProcessError:
        return "(no matches)"
    except FileNotFoundError as e:
        return f"Error: {e}"

async def main():
    async with stdio_server() as (reader, writer):
        await server.run(reader, writer)

if __name__ == "__main__":
    anyio.run(main)

Make it executable:

chmod +x server.py

That’s it. This server will communicate over stdio when launched, advertise two tools (uptime and grep_log), and run commands safely within the process you control.


2b) Build a minimal MCP server (Node.js path)

  • Initialize and install the SDK
mkdir -p ~/mcp-demo-node
cd ~/mcp-demo-node
npm init -y
npm install @modelcontextprotocol/sdk zod
  • Create server.mjs
#!/usr/bin/env node
import { execSync } from "node:child_process";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const server = new Server(
  { name: "demo-mcp-node", version: "0.1.0" },
  { capabilities: { tools: {} } }
);

// Example: uptime tool (no args)
server.tool(
  "uptime",
  {
    description: "Return human-friendly system uptime",
    inputSchema: z.object({}),
  },
  async () => {
    const out = execSync("uptime -p").toString().trim();
    return { content: [{ type: "text", text: out }] };
  }
);

// Example: wrap a grep on a log with guardrails
server.tool(
  "grep_log",
  {
    description: "Grep the last N lines of a log for a pattern",
    inputSchema: z.object({
      pattern: z.string(),
      log_path: z.string().default("/var/log/syslog"),
      max_lines: z.number().int().min(1).max(10000).default(50),
    }),
  },
  async ({ pattern, log_path, max_lines }) => {
    try {
      const tail = execSync(`tail -n ${max_lines} ${log_path}`, { encoding: "utf8" });
      const grep = execSync(`grep -i ${JSON.stringify(pattern)}`, { input: tail, encoding: "utf8" });
      const text = grep.trim() || "(no matches)";
      return { content: [{ type: "text", text }] };
    } catch {
      return { content: [{ type: "text", text: "(no matches)" }] };
    }
  }
);

const transport = new StdioServerTransport();
await server.connect(transport);

Make it executable:

chmod +x server.mjs

Note: The Node SDK example above uses the standard stdio transport and Zod schemas to validate tool inputs.


3) Run and wire it up

MCP clients typically start your server as a subprocess over stdio. Until you attach a client, you can at least confirm the process starts cleanly.

  • Start the Python server
cd ~/mcp-demo-python
. .venv/bin/activate
./server.py
  • Or start the Node server
cd ~/mcp-demo-node
node ./server.mjs

You won’t see much output by design—MCP speaks JSON over stdio. The real test is to connect it in an MCP-aware client.

  • Example client configuration snippet (adjust to your client’s docs)

Many clients accept a config that maps a server name to a command and args. For example:

{
  "mcpServers": {
    "demo-mcp-python": {
      "command": "/home/youruser/mcp-demo-python/.venv/bin/python",
      "args": ["/home/youruser/mcp-demo-python/server.py"]
    },
    "demo-mcp-node": {
      "command": "node",
      "args": ["/home/youruser/mcp-demo-node/server.mjs"]
    }
  }
}

Check your client’s documentation for where to place this JSON and how to enable MCP servers. The pattern is the same: the client launches your command and communicates over stdio.


4) Make it real: wrap a Bash task you actually use

Linux makes this fun. Here are a few useful patterns you can adapt:

  • Disk space snapshot

Python tool body:

import shutil
def disk_free(path: str = "/") -> str:
    total, used, free = shutil.disk_usage(path)
    gi = 1024**3
    return f"path={path} free={free//gi}Gi used={used//gi}Gi total={total//gi}Gi"

Node tool handler:

server.tool(
  "disk_free",
  { description: "Disk usage summary", inputSchema: z.object({ path: z.string().default("/") }) },
  async ({ path }) => {
    const out = execSync(`df -h ${JSON.stringify(path)} | tail -n +2`, { encoding: "utf8" }).trim();
    return { content: [{ type: "text", text: out }] };
  }
);
  • HTTP check with curl

Python tool body:

def http_head(url: str) -> str:
    out = subprocess.check_output(["curl", "-I", "-sS", url], text=True, stderr=subprocess.STDOUT)
    return out.strip()
  • Kubernetes pod grep (if kubectl is configured)

Node tool handler:

server.tool(
  "kube_grep_pods",
  { description: "Search pod names by pattern",
    inputSchema: z.object({ ns: z.string().default("default"), pattern: z.string() }) },
  async ({ ns, pattern }) => {
    const out = execSync(`kubectl get pods -n ${JSON.stringify(ns)} -o name | grep -i ${JSON.stringify(pattern)}`, { encoding: "utf8" });
    return { content: [{ type: "text", text: out.trim() || "(no matches)" }] };
  }
);

Always validate and sanitize inputs. Keep a tight allowlist of commands and arguments when exposing system capabilities.


5) Run it reliably with systemd (user service)

Keep your MCP server available for clients without manual starts.

  • Python example service
mkdir -p ~/.config/systemd/user
cat > ~/.config/systemd/user/mcp-demo-python.service << 'EOF'
[Unit]
Description=Demo MCP server (Python)

[Service]
Type=simple
WorkingDirectory=%h/mcp-demo-python
ExecStart=%h/mcp-demo-python/.venv/bin/python %h/mcp-demo-python/server.py
Restart=on-failure
RestartSec=2s

[Install]
WantedBy=default.target
EOF

systemctl --user daemon-reload
systemctl --user enable --now mcp-demo-python.service
journalctl --user -u mcp-demo-python.service -f
  • Node example service
cat > ~/.config/systemd/user/mcp-demo-node.service << 'EOF'
[Unit]
Description=Demo MCP server (Node.js)

[Service]
Type=simple
WorkingDirectory=%h/mcp-demo-node
ExecStart=/usr/bin/node %h/mcp-demo-node/server.mjs
Restart=on-failure
RestartSec=2s

[Install]
WantedBy=default.target
EOF

systemctl --user daemon-reload
systemctl --user enable --now mcp-demo-node.service
journalctl --user -u mcp-demo-node.service -f

Note: Replace paths if your node binary lives elsewhere (check with which node).


Troubleshooting tips

  • Command not found: Ensure the command your tool calls exists (e.g., curl, kubectl) and is on PATH for systemd user services. You may need to set Environment=PATH=... in the unit.

  • Permissions: Reading logs or system info may require elevated permissions. Prefer least privilege and consider dedicated logs or sudo rules only where necessary.

  • Stdio noise: Avoid printing to stdout in your server code—stdio is the protocol channel. Log to stderr or files if needed.


Conclusion and next steps

You just stood up a real MCP server on Linux, exposed a couple of practical tools, and made it durable with systemd. From here:

  • Add more tools that wrap the scripts you already trust.

  • Gate dangerous actions behind explicit arguments and schema validation.

  • Register your server with your favorite MCP-compatible client and iterate.

  • Version your server, write a short README for teammates, and share.

Call to action: Pick one real task you do every day (log triage, disk checks, service probes), wrap it as a new tool, and wire it into your MCP client today. Your command line just became AI-callable—securely and on your terms.