Run 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. That client is the piece people mean when they say agent harness: the loop around the model that owns the tool list, the permission checks and the session state, and MCP is simply how you extend the tool half of it. One protocol, so a server you write once works with every client that speaks MCP. If that split is new to you, and especially the question of how a model decides to reach for a tool at all, a staged path through the agent fundamentals is worth an hour before you hand one of these servers real credentials.
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/apiThe -- 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 listA 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_text_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. Once the agent holds write_file as well as read_text_file, it is worth pairing that reach with a skill that pushes it toward the smallest change that works, because a filesystem tool makes a sprawling rewrite exactly as cheap as a two-line fix. The same wiring extends past local files: if you already run a search engine on the VPS, you can hand the agent your own SearXNG instance as a search tool, which keeps the queries on your box but pulls untrusted page text straight into the context the agent then acts on.
Step 2: build a remote HTTP server
A stdio server dies with its parent, and it is spawned once per client, so if you run two Claude Code sessions on the box that hand work to each other, each gets its own private copy of the tool. 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.mcpserver import MCPServer
import subprocess
mcp = MCPServer("ops-tools")
@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", host="127.0.0.1", port=8000)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. This is the 2.x SDK that pip install "mcp[cli]" pulls today (2.3.0 as of 2026-10-12). It renamed FastMCP to MCPServer and moved host and port from the constructor to run(), and the old mcp.server.fastmcp import now fails with ModuleNotFoundError. 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. That matters most when the client is itself a long-lived process: an always-on agent that keeps its memory and schedules across reboots will call these tools on a schedule with nobody watching, so the server has to come back on its own too. 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.targetThe 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/mcpstatus 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. How much a stolen token buys you also depends on the server behind it: the read-only MCP server that ships with the openGym workout tracker can only ever hand back training data, while a filesystem or shell tool hands over the box.
Three defences, in order of preference:
- Do not publish it. Keep the server on
127.0.0.1and reach it from your laptop with an SSH tunnel:ssh -L 8000:127.0.0.1:8000 matt@vps, then point the client athttp://127.0.0.1:8000/mcp. Nothing is ever exposed. - 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.
- 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. If the variable is unset, Claude Code does not substitute an empty string: it flags the missing variable in claude mcp list and uses the literal ${MCP_TOKEN} text. Pick a name of your own, too, because in a remote server's headers Claude Code deliberately reads its own and your cloud provider's credential variables, such as ANTHROPIC_API_KEY, as empty.
Every defence above guards the endpoint rather than the agent already holding the token, which is the other half of the problem: if your client is the DeepSeek Harness, plugins that gate which tools an agent may call and scan tool output for injected instructions cover that side.
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 /tmpIt starts one web server on http://localhost:6274 and prints a URL carrying an MCP_INSPECTOR_API_TOKEN query string; open that link rather than typing the address from memory. Since v2 (2.10.1 is current as of 2026-10-12) the backend also injects the token into the page it serves, so a reload or a bookmark keeps working, and the token guards the /api/* routes that can spawn processes. The separate v1 proxy on 6277 is gone; the only extra listener is a sandbox for MCP Apps, on 6275 by default. Connect, open the Tools tab, pick a tool, fill in real arguments and call it. 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, launch the Inspector with no command, add a server with the Streamable HTTP transport and https://mcp.example.com/mcp, put the Authorization header in its settings, and connect, this is the fastest way to prove auth and the proxy are correct before any agent is involved. The Inspector saves headers in its server catalog exactly as written, so that token now also sits in ~/.mcp-inspector/mcp.json.
Keeping servers updated
MCP moves fast, so patch on a schedule. Node servers launched with an unpinned npx -y run whatever version npx resolves: the one installed in the local project if there is one, otherwise a copy it installs into the npm cache. -y only skips the install prompt, it does not force an update, so you neither track the latest release nor know for sure which one you are running. 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. Read the release notes before a major bump: the move from the 1.x to the 2.x Python SDK breaks 1.x code on import, and the SDK's own migration guide advises keeping a <2 upper bound, such as "mcp[cli]<2", until you have migrated. 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 current versions append the failure detail to that line. 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 variable did not expand, a common trap. If MCP_TOKEN is unset when the client reads the config, Claude Code warns about the missing variable in claude mcp list and sends the literal text, so nginx sees Bearer ${MCP_TOKEN}. A variable named after one of Claude Code's or your cloud provider's own credentials reads as empty instead, and nginx 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.