AI and LLMsWritten by GLWS18 min readUbuntu 24.04 LTS / AlmaLinux 9
Commands run on your server unless a section says otherwise. Replace example usernames, addresses and paths with your own values. Keep a working session open when changing remote access.

What MCP is

The Model Context Protocol (MCP) is an open standard that lets an AI application call tools and read data that live in your own software. An MCP server exposes tools, resources and prompts. A client, such as a desktop assistant or a coding agent, connects to it and lets the model use those tools, normally with the user's approval.

There are two ways to run a server. Over stdio, the client starts the program on your own computer and talks to it through its input and output. Over HTTP, the server runs on a machine like your VPS and clients connect to a URL. This guide builds a tiny server and covers both.

The SDK and the protocol are changing quickly. Check the official documentation if a command here no longer works.

Install the tooling

Install uv, a fast Python package manager, then create a project and add the MCP SDK. uv installs a suitable Python for you if needed. Read the installer before you run it, as with any script from the internet.

Both systems
curl -LsSf https://astral.sh/uv/install.sh -o uv-install.sh
less uv-install.sh
sh uv-install.sh
export PATH="$HOME/.local/bin:$PATH"
mkdir -p ~/mcp-demo && cd ~/mcp-demo
uv init --bare --python 3.12
uv add "mcp[cli]>=1,<2"

Write a small, safe server

This server has one tool that reports free disk space, and it only accepts paths from a short allowlist. That is the core security habit for MCP: expose narrow, read-only tools, validate every argument, and never pass model-provided text to a shell. The import falls back to the older name so the file works with both SDK generations.

Create server.py
cat > server.py <<'EOF'
import shutil

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("glws-demo", host="127.0.0.1")

ALLOWED_PATHS = {"/", "/var", "/home"}


@mcp.tool()
def disk_free(path: str = "/") -> str:
    """Report free disk space for an allowed path (read-only)."""
    if path not in ALLOWED_PATHS:
        return "Path not allowed. Choose one of: " + ", ".join(sorted(ALLOWED_PATHS))
    usage = shutil.disk_usage(path)
    gib = 1024 ** 3
    return f"{path}: {usage.free / gib:.1f} GiB free of {usage.total / gib:.1f} GiB"


@mcp.tool()
def add(a: int, b: int) -> int:
    """Add two integers."""
    return a + b
EOF
uv run python -c "import server; print('server imports OK')"

Test it with the inspector

The SDK includes a development inspector, a web page for calling your tools by hand. Run it and open the address it prints. Because it only listens on your machine, use an SSH tunnel if you run it on the VPS. Call each tool, including with bad input, and check what comes back.

Both systems
uv run mcp dev server.py
On your computer, if the inspector runs on the VPS (use the ports it prints)
ssh -N -L 6274:127.0.0.1:6274 -L 6277:127.0.0.1:6277 deploy@YOUR_SERVER

Run over stdio for your own computer

For a client on the same machine, you configure it to launch your server. In a desktop client, this is an entry in its MCP settings giving a command and arguments. The important rule for stdio servers is that standard output carries the protocol, so never print() debug text. Write logs to standard error or a file.

Example client entry (adjust the path)
{
  "mcpServers": {
    "glws-demo": {
      "command": "uv",
      "args": ["--directory", "/home/you/mcp-demo", "run", "mcp", "run", "server.py"]
    }
  }
}

Run over HTTP on the VPS

To let remote clients reach it, run the streamable HTTP transport. It listens on localhost port 8000 at the path /mcp by default. Keep it on localhost and put a reverse proxy in front for HTTPS and authentication. Do not open port 8000 in the firewall.

Both systems
uv run mcp run server.py --transport streamable-http
In another session, check it answers (a 4xx or protocol message is normal without a client)
curl -si http://127.0.0.1:8000/mcp | head -n 5
ss -lntp | grep 8000

Run it as a service

Create a service user and a systemd unit so it starts at boot and restarts if it crashes, with the sandboxing options from the Node.js service guide. Install the project under /opt, owned by root, with its virtual environment inside it.

Build the virtual environment at its final path. Copying .venv from your home directory leaves interpreter paths pointing to the old location. The service cannot read home directories, so its managed Python installation also lives under /opt/mcp-demo. The project files and dependencies stay owned by root; mcpdemo only runs them.

Both systems
sudo useradd --system --home-dir /opt/mcp-demo --shell /usr/sbin/nologin mcpdemo
cd ~/mcp-demo
sudo install -d -m 0755 /opt/mcp-demo
sudo install -m 0644 pyproject.toml uv.lock server.py /opt/mcp-demo/
UV_BINARY="$(command -v uv)"
sudo env UV_PYTHON_INSTALL_DIR=/opt/mcp-demo/.python "$UV_BINARY" sync --project /opt/mcp-demo --python 3.12 --frozen --no-dev
sudo chown -R root:root /opt/mcp-demo
sudo chmod -R a+rX /opt/mcp-demo
sudo tee /etc/systemd/system/mcp-demo.service >/dev/null <<'EOF'
[Unit]
Description=MCP demo server
After=network.target

[Service]
User=mcpdemo
Group=mcpdemo
WorkingDirectory=/opt/mcp-demo
ExecStart=/opt/mcp-demo/.venv/bin/mcp run server.py --transport streamable-http
Restart=on-failure
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true

[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now mcp-demo
systemctl status mcp-demo --no-pager
RECORDED LAB OUTPUT · Ubuntu 24.04 LTS
Tools: disk_free, add
add(20,22) = 42
disk_free(/): /: 131.4 GiB free of 149.5 GiB
Unapproved path rejected
Service user interpreter: /opt/mcp-demo/.venv/bin/python
User=mcpdemo
ProtectHome=yes
ProtectSystem=strict
active
Recorded MCP protocol calls through the running service. The allowed calculation succeeded and an unapproved filesystem path was rejected.
If the service fails, inspect sudo journalctl -u mcp-demo -b -n 50 --no-pager. Confirm /opt/mcp-demo/.venv/bin/python is executable as mcpdemo and that no interpreter link points into /home or /root.

Publish it with HTTPS and a token

Use the reverse proxy guide to obtain a certificate for a name such as mcp.example.com, then proxy to 127.0.0.1:8000 with buffering off, since MCP can stream responses. Add a shared-secret check so that requests without a valid bearer token never reach the server. Keep the token in a file that only root can read, outside the Nginx configuration you share.

Create the token file
openssl rand -hex 32 | sudo tee /etc/nginx/mcp-token >/dev/null
sudo chmod 600 /etc/nginx/mcp-token
sudo cat /etc/nginx/mcp-token
Nginx: /etc/nginx/conf.d/mcp.example.com.conf (replace TOKEN with the value above)
map $http_authorization $mcp_ok {
    default 0;
    "Bearer TOKEN" 1;
}

server {
    listen 80;
    server_name mcp.example.com;

    location / {
        if ($mcp_ok = 0) { return 401; }
        proxy_pass http://127.0.0.1:8000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header Connection "";
        proxy_buffering off;
        proxy_read_timeout 3600s;
    }
}
Certificate and test
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d mcp.example.com
curl -si https://mcp.example.com/mcp | head -n 1
curl -si -H "Authorization: Bearer TOKEN" https://mcp.example.com/mcp | head -n 1
A shared secret is a minimum for a personal server. For several users or for clients that support it, use the authorisation features in the MCP specification and the SDK instead, so that each person has their own revocable credentials.

Security checklist for MCP servers

Expose the narrowest tools you can, and keep them read-only unless a write is the point. Validate every argument and use allowlists, as the example does. Never build shell commands or SQL from model text. Run the server as an unprivileged user with sandboxing. Authenticate remote access, use HTTPS, log tool calls without logging secrets, and review which clients can connect. Remember that a tool's output goes back to the model, and that content from web pages or documents can contain instructions aimed at the model, so do not let a tool that reads untrusted text sit beside a tool that can change things without approval.

Official documentation

These upstream references provide more detail on the commands and workflows in this guide.