SSD Nodes Learn
Guides Matt ConnorBy Matt Connor

MCP servers on a VPS for AI coding agents

Run Model Context Protocol servers on your own VPS so AI agents get real tools: stdio and remote HTTP transports, systemd, TLS, auth, and every failure mode.

What you are building

Two working MCP setups on one VPS. First a stdio server — a filesystem or database tool that Claude Code launches as a child process and talks to over a pipe. Then a remote HTTP server that runs as a long-lived network service behind systemd and an nginx reverse proxy with TLS, reachable by any MCP client you point at it. The install for either is small. Most of this guide is the two things that actually bite: keeping the JSON-RPC stream clean, and never putting an unauthenticated tool endpoint on the public internet.

What MCP actually is

The Model Context Protocol is a standard way for an AI client — Claude Code, Claude Desktop, the Gemini CLI on a VPS, or your own script — to call external tools and read external resources. The model itself does not run anything. It asks the client, the client speaks JSON-RPC 2.0 to an MCP server, the server runs the tool and hands the result back. One protocol, so a server you write once works with every client that speaks MCP.

There are two transports, and the whole rest of this guide splits along them:

  • stdio. The client spawns the server as a child process and exchanges newline-delimited JSON-RPC messages over its standard input and standard output. No network, no port, no auth — the trust boundary is the process itself. Almost every local tool ships this way.
  • Streamable HTTP (and its older cousin, HTTP+SSE). The server is a long-running web service. The client connects over HTTP and the server can stream responses back as Server-Sent Events. This is how you share one server with many clients, or run a tool that must live on the box permanently.

Pick stdio when the tool belongs to one machine and one user. Pick HTTP when it is a shared service.

Prerequisites and the honest gotchas

Assume a fresh Ubuntu 24.04 KVM VPS with root or sudo. Beyond that:

  • A runtime the server is written in. Most reference servers are Node or Python. Ubuntu 24.04 ships Node 18, and several current MCP packages want Node 20 or newer, so install a current LTS from NodeSource or nvm rather than trusting apt. Python 3.12 is already present.
  • A domain and DNS A record, but only for the remote HTTP server — TLS needs a name that resolves to this VPS. The stdio example needs no DNS at all.
  • 512 MB RAM is plenty. MCP servers are thin JSON-RPC processes; the memory cost is whatever your tool touches (a database driver, a file cache), not the protocol.
  • The spec is young and moving. The 2025-03-26 revision replaced HTTP+SSE with Streamable HTTP and marked SSE deprecated. SSE still works and plenty of servers still speak it, so treat any transport pin as a thing to re-check against the server's release notes rather than gospel.

Step 1: wire a stdio server into Claude Code

Start with the filesystem server — it is official, actively maintained, and needs nothing but Node. The one command below registers it with Claude Code and scopes it to the current project so it lands in a committable file:

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

The -- separator matters: everything after it is the command Claude Code will run, not a flag for Claude Code. That writes a .mcp.json at the project root:

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

Nothing is running yet. When you next start Claude Code in this directory, the agent reads .mcp.json, spawns npx -y @modelcontextprotocol/server-filesystem ... as a child process, and performs the MCP handshake over that process's stdin/stdout. Confirm it took:

claude mcp list

A healthy server prints its command and a green tick — filesystem: npx -y @modelcontextprotocol/server-filesystem ... - ✓ Connected. Inside the session, the /mcp slash command lists the tools the server exposes (read_file, write_file, list_directory), and the agent can now call them on the paths you allowed. A database tool is the same shape — swap the package and pass a connection string as its final argument — but check the server's own repository for the current package name, since the reference Postgres server has moved hands more than once.

This is the whole point of running the agent on the box: the Claude Code session lives on the VPS inside tmux, and its stdio servers run right beside it with direct access to the project files and local services, no network round-trip.

Step 2: build a remote HTTP server

A stdio server dies with its parent. When you want a tool that stays up for every client — a shared ops tool, a database gateway, something your laptop and your CI both call — you need the HTTP transport and a real service. Here is a minimal Python server using the official SDK, exposing 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")

Note host="127.0.0.1". The server binds to localhost only — nothing outside the box can reach it directly, which is exactly what you want before auth exists. Install it in its own virtualenv so systemd has a 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: keep it alive with systemd

A tool that is down when the agent reaches for it is worse than no tool. 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

The absolute path to the venv Python in ExecStart is not optional — point it at /usr/bin/python3 and the process starts with ModuleNotFoundError: No module named 'mcp', because the system interpreter never saw your pip install. Enable and check:

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 should read active (running). The curl comes back HTTP/1.1 400 Bad Request with a JSON-RPC error in the body — the request carried no session and no valid JSON payload — and that is exactly what you want: it proves the port answers and speaks the protocol. Connection refused or an empty reply means the process is not bound where you think; read journalctl -u mcp-ops -n 50.

Step 4: put TLS and a reverse proxy in front

The server listens on localhost. To reach it from anywhere you terminate TLS at nginx and proxy inward. Install nginx, get a certificate with Certbot and Let's Encrypt on nginx, then write the location block. The critical part is disabling buffering, because the default nginx behaviour holds a response until it is complete, which stalls an 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 already run a fleet of containers, the same job is done for you by a Traefik reverse proxy with automatic TLS — it issues the certificate and routes by hostname, and you just add labels to the MCP container. Either way, the reverse proxy is now the only thing on a public port, and it points at a service you have not secured yet. Fix that before you register the URL anywhere.

Step 5: the security rule that dominates this topic

Never expose an unauthenticated MCP endpoint. An MCP server is not a read-only API. It grants tool access — to your files, your database, sometimes a shell. An open /mcp on the public internet is a stranger with the same reach as your AI agent: they list your tools, then call them. Treat it exactly like an unauthenticated admin socket, because that is what it is.

Three defences, in order of preference:

  1. Do not publish it. Keep the server on 127.0.0.1 and reach it from your laptop with an SSH tunnel: ssh -L 8000:127.0.0.1:8000 matt@vps, then point the client at http://127.0.0.1:8000/mcp. Nothing is ever exposed.
  2. Put it on a private network. Bind the tunnel address of a self-hosted WireGuard VPN and let only VPN peers reach it. The public internet sees a closed port.
  3. If it must be public, require a token. The proper answer is the MCP OAuth flow that HTTP transport supports natively. The pragmatic minimum is a shared bearer token checked at the proxy — cheap, and it stops the drive-by 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 never bind the server itself to 0.0.0.0 without one of these in front. The client then sends the token as a header. In Claude Code:

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

Set MCP_TOKEN in your shell so the secret never lands in .mcp.json in plaintext — Claude Code expands ${MCP_TOKEN} from the environment at read time.

Step 6: debug with the MCP Inspector

When a server misbehaves, do not guess from inside the agent — drive it directly with the Inspector, the official web-based test client. For a stdio server, hand it the same command the agent runs:

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

It starts a UI on http://localhost:6274 (recent versions print a URL with a MCP_PROXY_AUTH_TOKEN query string — use that exact link or the UI rejects you) and a proxy on 6277. Click Connect, then List Tools, then Call Tool with real arguments. If it works in the Inspector but fails in the agent, the bug is in your client config, not the server. For the remote HTTP server, choose the Streamable HTTP transport, enter https://mcp.example.com/mcp, add the Authorization header, and connect — this is the fastest way to prove auth and the proxy are correct before any agent is involved.

Keeping servers updated

MCP moves fast, so patch on a schedule. Node servers launched with npx -y fetch the latest version each spawn, which is convenient but non-reproducible; pin the exact version you tested — read it from npm view @modelcontextprotocol/server-filesystem version and append it to the package name in .mcp.json (@modelcontextprotocol/server-filesystem@<version>) — once a server matters, and bump it deliberately. Python servers under systemd update with sudo -H -u mcp /opt/mcp-ops/.venv/bin/pip install -U "mcp[cli]" followed by sudo systemctl restart mcp-ops. Watch the spec revision your SDK targets when you upgrade — a jump across the SSE-to-Streamable-HTTP boundary can change the transport your clients must request.

Failure modes, with the strings you will see

The agent shows the server failed. claude mcp list prints ✗ Failed to connect, and the TUI reports MCP server 'filesystem' failed to start. Run claude --debug and you will usually see Error: spawn npx ENOENT — the command is not on the agent's PATH. The runtime is missing or not where the agent looks: Node not installed, npx absent, or a virtualenv Python referenced by bare name. Fix the command to an absolute path or install the runtime, then reconnect.

A stdio server connects, then instantly drops. The client logs a JSON parse error — something like Unexpected token 'S', "Server sta"... is not valid JSON or Failed to parse message. The cause is always the same: the server wrote a log line to stdout. On stdio, stdout is the JSON-RPC channel, so any stray text corrupts the stream and the handshake dies. In Node, console.log goes to stdout — use console.error. In Python, a bare print() goes to stdout — write logs with logging configured to sys.stderr, or pass file=sys.stderr. The rule is absolute: on stdio, only JSON-RPC on stdout, everything human on stderr.

A remote server times out or closes mid-handshake. The client fails with MCP error -32000: Connection closed, or the Inspector hangs on Connect and never lists tools. Behind nginx this is buffering: the proxy holds the SSE stream instead of flushing it, so the client waits for a response that never arrives. Add proxy_buffering off; (and the rest of the block in Step 4) to the location. Confirm with curl -N against the public URL — you should see event data arrive incrementally, not all at once at the end.

Auth is rejected. The client reports Error POSTing to endpoint (HTTP 401) or plainly 401 Unauthorized. Either the header is missing, the token is wrong, or the shell variable was empty when the client read the config — a common trap, since ${MCP_TOKEN} expands to nothing if the variable is unset and nginx then sees Bearer with no value. Echo the variable, re-add the header, and verify the exact bytes match the token in the nginx if.

The service will not start under systemd. journalctl -u mcp-ops shows ModuleNotFoundError: No module named 'mcp'ExecStart points at the system Python instead of the venv interpreter. Or Address already in use — another process holds 8000; find it with sudo ss -ltnp | grep 8000.

FAQ

What is an MCP server, exactly?

It is a program that exposes tools and resources to an AI client over the Model Context Protocol, using JSON-RPC 2.0. The AI model never runs the tool itself — it asks its client, the client calls the MCP server, and the server executes and returns a result. Because the protocol is standard, one server works with any compliant client, whether that is Claude Code, Claude Desktop, or the Gemini CLI.

What is the difference between stdio and HTTP transport?

A stdio server is launched by the client as a child process and communicates over stdin/stdout, so it lives and dies with one client on one machine and needs no network or auth. An HTTP server is a long-running network service that many clients can reach at once, which is why it requires TLS and authentication. Use stdio for local, single-user tools; use HTTP (Streamable HTTP on current servers) for anything shared or persistent.

How do I secure a remote MCP server?

Assume it grants tool access to your files, database, or shell, and never expose it unauthenticated. Best is to keep it bound to localhost and reach it over an SSH tunnel or a private VPN; if it must be public, put it behind a reverse proxy that enforces a bearer token or the MCP OAuth flow. Generate the token with openssl rand -hex 32 and never bind the server to 0.0.0.0 without one of these in front.

How do I debug a server that will not start?

First check claude mcp list✗ Failed to connect with spawn ... ENOENT means the command or runtime is missing, so fix the path or install it. If it connects then drops with a JSON parse error, the server is logging to stdout and corrupting the JSON-RPC stream; move all logging to stderr. For anything else, run the exact command under the MCP Inspector, which drives the server in isolation so you can tell a server bug from a client-config bug.