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.
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.
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.
uv run mcp dev server.pyssh -N -L 6274:127.0.0.1:6274 -L 6277:127.0.0.1:6277 deploy@YOUR_SERVERRun 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.
{
"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.
uv run mcp run server.py --transport streamable-httpcurl -si http://127.0.0.1:8000/mcp | head -n 5
ss -lntp | grep 8000Run 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.
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-pagerTools: 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
activePublish 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.
openssl rand -hex 32 | sudo tee /etc/nginx/mcp-token >/dev/null
sudo chmod 600 /etc/nginx/mcp-token
sudo cat /etc/nginx/mcp-tokenmap $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;
}
}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 1Security 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.