SSD Nodes Learn Hosting plans →
How to do am Matt ConnorBy Matt Connor · Updated 2026-08-29

How to Run MCP Servers on VPS for AI Agents

Run MCP on your VPS with stdio or remote HTTP, systemd, nginx TLS, and auth. Avoid broken JSON-RPC streams and unsecured public tool endpoints.

Wetin you dey build

Two working MCP setups for one VPS. First one na stdio server, wey fit be filesystem or database tool wey Claude Code go launch as child process and talk to through pipe. Then na remote HTTP server wey go run as long-lived network service behind systemd and nginx reverse proxy with TLS. Any MCP client wey you point to am fit reach the server. Installation for either one no big. Most part of this guide dey focus on the two things wey dey cause real wahala: make JSON-RPC stream remain clean, and never put tool endpoint wey no get authentication for public internet.

Wetin MCP really be

Model Context Protocol na standard way wey AI client, Claude Code, Claude Desktop, the Gemini CLI for VPS, or your own script fit use call external tools and read external resources. The model by itself no dey run anything. E go ask the client, the client go use JSON-RPC 2.0 talk to MCP server, the server go run the tool and send the result back. Na this client people dey mean when dem talk say agent harness: na the loop around the model wey dey manage tool list, permission checks, and session state. MCP simply na the way to extend the tool part. Na one protocol, so any server wey you write once fit work with every client wey support MCP. If this separation be new to you, especially the question of how model decide to use tool at all, one staged path through agent fundamentals fit worth one hour before you give any of these servers real credentials.

Two transports dey, and the remaining part of this guide go follow dem:

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

Choose stdio when the tool belong to one machine and one user. Choose HTTP when na shared service.

Prerequisites and the honest gotchas

Assume say na fresh Ubuntu 24.04 KVM VPS wey get root or sudo. Apart from that:

  • A runtime wey dem use write the server. Most reference servers na Node or Python. Ubuntu 24.04 dey ship Node 18, and some current MCP packages need Node 20 or newer, so install current LTS from NodeSource or nvm instead of trusting apt. Python 3.12 don already dey available.
  • A domain and DNS A record, but na only for the remote HTTP server; TLS need name wey dey resolve to this VPS. The stdio example no need DNS at all.
  • 512 MB RAM plenty. MCP servers na lightweight JSON-RPC processes; na the tool wey you use dey determine memory cost, such as database driver or file cache, not the protocol.
  • The spec still young and dey change. The 2025-03-26 revision replace HTTP+SSE with Streamable HTTP and mark SSE as deprecated. SSE still dey work, and plenty servers still speak am, so treat any transport pin as something wey you need check again against the server's release notes, not as final rule.

Step 1: connect one stdio server to Claude Code

Start with the filesystem server. E official, dem dey maintain am well, and e only need Node. The one command below go register am with Claude Code and limit am to the current project, so e go enter one file wey you fit commit:

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

The -- separator dey important: everything after am na the command wey Claude Code go run, no be flag for Claude Code. That command go write one .mcp.json for the project root:

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

Nothing dey run yet. When you start Claude Code again for this directory, the agent go read .mcp.json, start npx -y @modelcontextprotocol/server-filesystem ... as child process, and do the MCP handshake through that process stdin/stdout. Confirm say e work:

claude mcp list

Healthy server go print its command and one green tick, filesystem: npx -y @modelcontextprotocol/server-filesystem ... - ✓ Connected. Inside the session, the /mcp slash command go list the tools wey the server provide (read_file, write_file, list_directory), and the agent fit now call dem for the paths wey you allow. Database tool get the same structure. Change the package and pass connection string as the last argument, but check the server own repository for the current package name, because different people don take over the reference Postgres server more than once.

Na this be the main reason to run the agent for the server: the Claude Code session dey live for the VPS inside tmux, and its stdio servers dey run right beside am with direct access to project files and local services, without network round-trip. Once the agent get write_file and read_file, e make sense to join that access with one skill wey go guide am toward the smallest change wey go work, because filesystem tool make big rewrite cost the same as two-line fix. This same wiring fit work beyond local files: if search engine already dey run for the VPS, you fit give the agent your own SearXNG instance as search tool, so the queries remain for your server but untrusted page text go enter the context wey the agent go then act on.

Step 2: build remote HTTP server

A stdio server go die together with e parent, and e dey start once for each client. So, if you run two Claude Code sessions for the box wey dey hand work to each other, each one go get e own private copy of the tool. When you need a tool wey go remain active for every client, like shared ops tool, database gateway, or something wey your laptop and CI go call, you need HTTP transport and real service. See one minimal Python server wey use the official SDK and 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")

Note host="127.0.0.1". The server bind to localhost only, so nothing outside the box fit reach am directly. Na exactly wetin you want before auth dey available. Install am for e own virtualenv so 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 stay alive with systemd

Tool wey no dey up when agent need am worse pass make you no get tool at all. This matter important pass when client itself na long-running process: an agent wey dey always on and dey keep im memory and schedules across reboots go call these tools according to schedule, and nobody go dey watch am. So server too gats come back by itself. 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 for ExecStart no be optional. Point am to /usr/bin/python3, and process go start with ModuleNotFoundError: No module named 'mcp', because system interpreter never see your pip install. Enable am 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 show active (running). curl go return HTTP/1.1 400 Bad Request with JSON-RPC error inside the body. The request no get session and no valid JSON payload, and na exactly wetin you want: e prove say the port dey answer and e dey speak the protocol. Connection refused or empty reply mean say process no dey bound for where you think; read journalctl -u mcp-ops -n 50.

Step 4: set TLS and reverse proxy for front

Server dey listen for localhost. To reach am from anywhere, terminate TLS for nginx, then proxy request go inside. Install nginx, collect certificate with Certbot and Let's Encrypt for nginx, then write the location block. The important part na to disable buffering, because default nginx behaviour dey hold response until e complete, and this go stall 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 dey run plenty containers, Traefik reverse proxy with automatic TLS fit do the same work for you. E go issue certificate and route request by hostname, while you only add labels to the MCP container. Either way, na reverse proxy be the only thing wey dey for public port now, and e dey point to service wey you never secure yet. Fix that before you register the URL anywhere.

Step 5: the security rule wey dey control this topic

Never expose an unauthenticated MCP endpoint. MCP server no be read-only API. E dey give access to tools for your files, your database, and sometimes a shell. An open /mcp for public internet na stranger wey get the same reach as your AI agent: dem fit list your tools, then call dem. Treat am exactly like unauthenticated admin socket, because na wetin e be. How much stolen token fit buy you still depend on the server wey dey behind am: the read-only MCP server wey dey ship with the openGym workout tracker fit only ever return training data, while filesystem or shell tool fit hand over the whole box.

Three defences, from the best option:

  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, then point the client to http://127.0.0.1:8000/mcp. Nothing go ever expose.
  2. Put am for private network. Bind the tunnel address of a self-hosted WireGuard VPN and allow only VPN peers to reach am. Public internet go see closed port.
  3. If e must dey public, require token. The correct answer na MCP OAuth flow wey HTTP transport support natively. The practical minimum na shared bearer token wey proxy go check. E cheap, and e go stop drive-by access completely:
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 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 the secret no go land for .mcp.json as plaintext. Claude Code go expand ${MCP_TOKEN} from the environment when e dey read am.

Every defence above dey protect the endpoint, not the agent wey already hold the token. That na the other half of the problem: if your client na DeepSeek Harness, plugins wey control which tools agent fit call and scan tool output for injected instructions go cover that side.

Step 6: debug with the MCP Inspector

When server dey misbehave, no dey guess from inside agent. Use Inspector directly, na the 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 dey print URL with MCP_PROXY_AUTH_TOKEN query string; use that exact link, otherwise UI no go accept you) and proxy for 6277. Click Connect, then List Tools, then Call Tool with real arguments. If e work for Inspector but e fail for agent, the bug dey your client config, no be for server. For remote HTTP server, choose Streamable HTTP transport, enter https://mcp.example.com/mcp, add Authorization header, then connect. This na the fastest way to prove say auth and proxy correct before any agent dey involved.

Keeping servers updated

MCP dey move fast, so make you patch am on schedule. Node servers wey you launch with npx -y go fetch the latest version every time dem spawn. E convenient, but you no fit reproduce the exact environment. Pin the exact version wey you test, read am from npm view @modelcontextprotocol/server-filesystem version, then add am to the package name for .mcp.json (@modelcontextprotocol/server-filesystem@<version>). Do this once the server become important, and update the version deliberately. Python servers under systemd dey 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 wey your SDK dey target when you upgrade. Moving across the SSE-to-Streamable-HTTP boundary fit change the transport wey your clients must request.

Failure modes, with the strings wey you go see

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

A stdio server dey connect, then e drop immediately. The client logs JSON parse error, something like Unexpected token 'S', "Server sta"... is not valid JSON or Failed to parse message. The cause always na the same: the server write log line to stdout. For stdio, stdout na the JSON-RPC channel, so any extra text go corrupt the stream and handshake go fail. 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 no get exception: for stdio, na only JSON-RPC fit dey stdout; every human-readable thing go stderr.

A remote server dey timeout or close during handshake. The client fail with MCP error -32000: Connection closed, or the Inspector hang for Connect and e no ever list tools. If nginx dey front, na buffering cause am: the proxy hold the SSE stream instead of flushing am, so the client dey wait for response wey no go arrive. Add proxy_buffering off;, plus the remaining block for Step 4, to location. Confirm am with curl -N against the public URL; event data suppose dey arrive bit by bit, no be all together at the end.

Auth dey reject. The client report Error POSTing to endpoint (HTTP 401) or simply 401 Unauthorized. Either the header no dey, the token wrong, or shell variable empty when the client read the config. This one common because ${MCP_TOKEN} expand to nothing if the variable no dey set, and nginx go then see Bearer with no value. Echo the variable, add the header again, and confirm say the exact bytes match the token for the nginx if.

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

FAQ

MCP server na wetin exactly?

Na program wey dey expose tools and resources to AI client through Model Context Protocol, using JSON-RPC 2.0. AI model no dey run the tool by itself. E go ask im client, the client go call MCP server, and the server go execute am then return result. Because the protocol standard, one server fit work with any client wey comply, whether na Claude Code, Claude Desktop, or Gemini CLI.

Wetin be the difference between stdio and HTTP transport?

Client dey launch stdio server 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 once. Na why e need TLS and authentication. Use stdio for local, single-user tools. Use HTTP (Streamable HTTP for current servers) for anything wey people share or wey need persist.

How I fit secure remote MCP server?

Assume say e dey grant tool access to your files, database, or shell, and never expose am without authentication. Best option na to bind am to localhost and reach am through SSH tunnel or private VPN. If e must be public, put am behind reverse proxy wey dey enforce bearer token or 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 I fit debug server wey no gree start?

First check claude mcp list, ✗ Failed to connect with spawn ... ENOENT. This one mean say the command or runtime dey missing, so fix the path or install am. If e connect first, then drop with JSON parse error, server dey log to stdout and dey corrupt the JSON-RPC stream; move all logging go stderr. For any other case, run the exact command under MCP Inspector. E go drive the server in isolation, so you fit know whether na server bug or client-config bug.