SSD Nodes Learn
How to do am Matt ConnorBy Matt Connor · Updated 2026-07-24

how to run MCP servers on VPS

Learn how to deploy MCP servers for AI agents using stdio and remote HTTP transports. We cover systemd, TLS, and how to fix JSON-RPC stream errors.

Wetin you dey build

Two working MCP setups for one VPS. First na stdio server — a filesystem or database tool wey Claude Code dey launch as child process and dem dey talk through pipe. Then na remote HTTP server wey dey run as long-lived network service behind systemd and nginx reverse proxy with TLS, wey any MCP client wey you point am at can reach. The install for both na small work. Most of this guide na for the two things wey dey wahala: how to keep the JSON-RPC stream clean, and how to make sure say you no put unauthenticated tool endpoint for public internet.

Wetin MCP really be

Model Context Protocol na standard way wey AI client — like Claude Code, Claude Desktop, the Gemini CLI on a VPS, or your own script — dey use call external tools and read external resources. The model itself no dey run anything. E go ask the client, the client go talk JSON-RPC 2.0 to an MCP server, then the server go run the tool and send the result back. One protocol only, so if you write server once, e go work with every client wey sabi speak MCP.

Two types of transports dey, and the rest of this guide dey split based on dem:

  • stdio. The client go start the server as child process and dem go exchange newline-delimited JSON-RPC messages through standard input and standard output. No network, no port, no auth — the trust boundary na the process itself. Almost every local tool dey come with this one.
  • Streamable HTTP (and its older cousin, HTTP+SSE). The server na long-running web service. The client go connect over HTTP and the server fit stream responses back as Server-Sent Events. Na how you go take share one server with many clients, or run tool wey must stay for the box permanently.

Pick stdio when the tool na for one machine and one user only. Pick HTTP when e be shared service.

Prerequisites and the honest gotchas

Assume say you get fresh Ubuntu 24.04 KVM VPS wey you get root or sudo access. Beyond that:

  • Runtime wey server dey use. Most reference servers dey use Node or Python. Ubuntu 24.04 come with Node 18, but many current MCP packages need Node 20 or something newer, so install current LTS from NodeSource or nvm instead of to rely on apt. Python 3.12 dey inside already.
  • Domain and DNS A record, but na only for the remote HTTP server — TLS need name wey go resolve to this VPS. The stdio example no need DNS at all.
  • 512 MB RAM plenty. MCP servers na thin JSON-RPC processes; the memory cost na whatever your tool dey use (a database driver, a file cache), no be the protocol.
  • The spec dey change small-small. The 2025-03-26 revision replace HTTP+SSE with Streamable HTTP and dem mark SSE as deprecated. SSE still dey work and many servers still dey use am, so if you see any transport pin, check am against the server's release notes instead of to take am as gospel.

Step 1: wire a stdio server into Claude Code

Start with the filesystem server — e be official, dem dey maintain am well, and e no need anything except Node. Dis single command below go register am with Claude Code and set am for dis current project so e go land inside one committable file:

cd /home/matt/projects/api
claude mcp add --scope project --transport stdio filesystem \
  -- npx -y @modelcontextprotocol/server-filesystem /home/matt/projects/api

Dis -- separator important: everything wey follow am dey after na dis command Claude Code go run, e no be flag for Claude Code. Dat one go write .mcp.json for di project root:

{
  "mcpServers": {
    "filesystem": {
      "command": "npx",
      "args": [
        "-y",
        "@modelcontextprotocol/server-filesystem",
        "/home/matt/projects/api"
      ]
    }
  }
}

Nothing dey run yet. Next time wey you start Claude Code for dis directory, di agent go read .mcp.json, spawn npx -y @modelcontextprotocol/server-filesystem ... as a child process, and perform di MCP handshake over dat process's stdin/stdout. Confirm say e work:

claude mcp list

If di server dey work well, e go print im command and one green tick — filesystem: npx -y @modelcontextprotocol/server-filesystem ... - ✓ Connected. Inside di session, di /mcp slash command go list di tools wey di server dey show (read_file, write_file, list_directory), and di agent fit call dem for di paths wey you allow. Database tool follow di same pattern — just swap di package and pass connection string as di last argument — but check di server repository for di current package name, because di reference Postgres server don change hands many times.

Na dis one be di main reason why dem dey run di agent on di box: di Claude Code session dey live for VPS inside tmux, and im stdio servers dey run right beside am with direct access to di project files and local services, without network round-trip.

Step 2: build a remote HTTP server

Stdio server dey die when its parent process die. If you want tool wey go stay up for every client — like shared ops tool, database gateway, or something wey your laptop and CI both go call — you need HTTP transport and real service. Here na minimal Python server wey use official SDK, e dey expose one tool:

# /opt/mcp-ops/server.py
from mcp.server.fastmcp import FastMCP
import subprocess

mcp = FastMCP("ops-tools", host="127.0.0.1", port=8000)

@mcp.tool()
def disk_free() -> str:
    """Return `df -h` for the server."""
    out = subprocess.run(["df", "-h"], capture_output=True, text=True)
    return out.stdout

if __name__ == "__main__":
    # Serves Streamable HTTP at /mcp on 127.0.0.1:8000
    mcp.run(transport="streamable-http")

Look host="127.0.0.1". Server dey bind to localhost only — nothing outside the box fit reach am directly, and na wetin you need before auth dey. Install am inside its own virtualenv so say systemd go get stable interpreter path:

sudo useradd --system --home /opt/mcp-ops --shell /usr/sbin/nologin mcp
sudo install -d -o mcp -g mcp /opt/mcp-ops
sudo -H -u mcp python3 -m venv /opt/mcp-ops/.venv
sudo -H -u mcp /opt/mcp-ops/.venv/bin/pip install "mcp[cli]"

Step 3: make am dey run with systemd

Tool wey no dey when agent wan call am bad pass no tool at all. Write /etc/systemd/system/mcp-ops.service:

[Unit]
Description=MCP ops-tools server
After=network.target

[Service]
Type=simple
User=mcp
WorkingDirectory=/opt/mcp-ops
ExecStart=/opt/mcp-ops/.venv/bin/python /opt/mcp-ops/server.py
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true

[Install]
WantedBy=multi-user.target

You must use the full path to the venv Python for ExecStart — point am to /usr/bin/python3 so the process fit start with ModuleNotFoundError: No module named 'mcp', because the system interpreter no go see your pip install. Enable and check am:

sudo systemctl daemon-reload
sudo systemctl enable --now mcp-ops
sudo systemctl status mcp-ops
curl -si -H 'Accept: application/json, text/event-stream' \
  -H 'Content-Type: application/json' \
  -X POST http://127.0.0.1:8000/mcp

status suppose read active (running). The curl go come back HTTP/1.1 400 Bad Request with JSON-RPC error for inside body — the request no carry session and no valid JSON payload — and na exactly wetin you want: e show say the port dey answer and e sabi the protocol. Connection refused or empty reply mean say the process no dey bind for where you think; read journalctl -u mcp-ops -n 50.

Step 4: Put TLS and a reverse proxy for front

Server dey listen for localhost. To reach am from anywhere, you must terminate TLS for nginx and proxy inward. Install nginx, get certificate use Certbot and Let's Encrypt on nginx, then write the location block. The most important part na to disable buffering. This one because default nginx behaviour dey hold response until e finish, and that one dey block SSE stream forever:

server {
    listen 443 ssl;
    server_name mcp.example.com;

    # ssl_certificate lines managed by Certbot

    location /mcp {
        proxy_pass http://127.0.0.1:8000;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;

        # The four lines that make SSE work through nginx:
        proxy_buffering off;
        proxy_cache off;
        proxy_read_timeout 3600s;
        chunked_transfer_encoding off;
    }
}

Reload with sudo nginx -t && sudo systemctl reload nginx. If you don dey run many containers already, Traefik reverse proxy with automatic TLS fit do this work for you — e dey issue certificate and route by hostname, you only need to add labels to the MCP container. Either way, the reverse proxy na the only thing wey dey open for public port, and e dey point to service wey you never secure yet. Fix that one before you register the URL anywhere.

Step 5: security rule wey be main for dis topic

No ever expose unauthenticated MCP endpoint. MCP server no be read-only API. E dey give tool access — to your files, your database, and sometimes shell. Open /mcp for public internet na stranger wey get same power as your AI agent: dem go list your tools, den dem go call dem. Treat am exactly like unauthenticated admin socket, because na wetin e be.

Three defences, according to preference:

  1. No publish am. Keep the server for 127.0.0.1 and reach am from your laptop with SSH tunnel: ssh -L 8000:127.0.0.1:8000 matt@vps, den point the client for http://127.0.0.1:8000/mcp. Nothing go ever expose.
  2. Put am for private network. Bind the tunnel address of self-hosted WireGuard VPN and make only VPN peers reach am. Public internet go see closed port.
  3. If e must be public, require token. The correct way na the MCP OAuth flow wey HTTP transport support natively. The simple minimum na shared bearer token wey proxy dey check — e easy, and e go stop drive-by attack entirely:
location /mcp {
    if ($http_authorization != "Bearer REPLACE_WITH_LONG_RANDOM") {
        return 401;
    }
    proxy_pass http://127.0.0.1:8000;
    # ...buffering-off block from above...
}

Generate the token with openssl rand -hex 32, and no ever bind the server itself to 0.0.0.0 without one of dis things for front. The client go then send the token as header. For Claude Code:

claude mcp add --scope project --transport http ops-tools https://mcp.example.com/mcp \
  --header 'Authorization: Bearer ${MCP_TOKEN}'

Set MCP_TOKEN for your shell so that the secret no go ever land for .mcp.json for plaintext — Claude Code dey expand ${MCP_TOKEN} from the environment when e dey read am.

Step 6: debug with the MCP Inspector

If server dey behave bad, no dey guess inside agent — use Inspector drive am directly. Inspector na official web-based test client. For stdio server, give am the same command wey agent dey run:

npx @modelcontextprotocol/inspector \
  npx -y @modelcontextprotocol/server-filesystem /tmp

E go start UI for http://localhost:6274 (recent versions go print URL wey get MCP_PROXY_AUTH_TOKEN query string — use that exact link or the UI go reject you) and proxy for 6277. Click Connect, then List Tools, then Call Tool with real arguments. If e dey work for Inspector but e dey fail for agent, the bug dey your client config, no be for server. For remote HTTP server, pick Streamable HTTP transport, enter https://mcp.example.com/mcp, add the Authorization header, and connect — na fastest way to prove auth and proxy dey correct before you use any agent.

Keeping servers updated

MCP dey move fast, so make you patch am for schedule. Node servers wey you launch with npx -y go dey fetch latest version every time dem spawn. Dis one easy but e no dey reproducible; pin de exact version wey you test — read am from npm view @modelcontextprotocol/server-filesystem version and add am to de package name for .mcp.json (@modelcontextprotocol/server-filesystem@<version>) — once server start to matter, you must bump am with care. Python servers wey dey run under systemd dey update with sudo -H -u mcp /opt/mcp-ops/.venv/bin/pip install -U "mcp[cli]" follow by sudo systemctl restart mcp-ops. Watch de spec revision wey your SDK dey target when you upgrade — if you jump across de SSE-to-Streamable-HTTP boundary, de transport wey your clients must request fit change.

Failure modes, with the strings you will see

The agent show say server fail. claude mcp list go print ✗ Failed to connect, and TUI go report MCP server 'filesystem' failed to start. Run claude --debug and you go usually see Error: spawn npx ENOENT — the command no dey for agent's PATH. The runtime missing or e no dey where agent dey look: Node no install, npx no dey, or you use bare name for Python virtualenv. Fix the command to absolute path or install the runtime, then reconnect.

A stdio server connect, then e drop instantly. Client log JSON parse error — something like Unexpected token 'S', "Server sta"... is not valid JSON or Failed to parse message. The cause na always the same: the server write log line to stdout. For stdio, stdout na the JSON-RPC channel, so any extra text go spoil the stream and the handshake go die. For Node, console.log dey go stdout — use console.error. For Python, bare print() dey go stdout — write logs with logging configured to sys.stderr, or pass file=sys.stderr. The rule na absolute: for stdio, only JSON-RPC for stdout, everything for human for stderr.

A remote server time out or close mid-handshake. Client fail with MCP error -32000: Connection closed, or Inspector go hang for Connect and e no go list tools. If e dey behind nginx, na buffering dey cause am: the proxy dey hold the SSE stream instead of to flush am, so client dey wait for response wey no go ever come. Add proxy_buffering off; (and the rest of the block for Step 4) to the location. Confirm with curl -N against the public URL — you suppose see event data dey come small-small, no be all at once for end.

Auth dey reject. Client report Error POSTing to endpoint (HTTP 401) or simply 401 Unauthorized. Either the header missing, the token wrong, or the shell variable empty when client read the config — na common trap, because ${MCP_TOKEN} go expand to nothing if the variable unset, and nginx go see Bearer with no value. Echo the variable, re-add the header, and verify say the exact bytes match the token for nginx if.

The service no wan start under systemd. journalctl -u mcp-ops show ModuleNotFoundError: No module named 'mcp'ExecStart dey point at the system Python instead of the venv interpreter. Or Address already in use — another process dey hold 8000; find am with sudo ss -ltnp | grep 8000.

FAQ

Wetin be MCP server exactly?

Na program wey dey give tools and resources to AI client through Model Context Protocol, e dey use JSON-RPC 2.0. AI model no dey run the tool itself — e go ask its client, client go call MCP server, and server go run am then return result. Because protocol na standard, one server fit work with any client wey follow rule, whether na Claude Code, Claude Desktop, or Gemini CLI.

Wetin be difference between stdio and HTTP transport?

Stdio server na client dey launch as child process and e dey communicate through stdin/stdout, so e dey live and die with one client for one machine and e no need network or auth. HTTP server na long-running network service wey many clients fit reach at the same time, na why e need TLS and authentication. Use stdio for local tools wey one person dey use; use HTTP (Streamable HTTP for current servers) for anything wey many people go use or wey go dey run always.

How I go secure remote MCP server?

Make you assume say e dey give tool access to your files, database, or shell, so no ever expose am without authentication. Best way na to keep am bound to localhost and reach am through SSH tunnel or private VPN; if e must dey public, put am behind reverse proxy wey dey enforce bearer token or MCP OAuth flow. Use openssl rand -hex 32 take generate the token and no ever bind the server to 0.0.0.0 without one of these things for front.

How I go debug server wey no wan start?

First check claude mcp list✗ Failed to connect with spawn ... ENOENT mean say command or runtime no dey, so fix the path or install am. If e connect then drop with JSON parse error, e mean say server dey log to stdout and e dey spoil the JSON-RPC stream; move all logging to stderr. For any other thing, run the exact command under MCP Inspector, wey dey run the server for isolation so you fit know if na server bug or client-config bug.