Short answer: A remote MCP server is a normal web service with one endpoint, such as /mcp, that speaks the Streamable HTTP transport. The spec requires you to validate the Origin header and says you should authenticate every connection. Below is a small Python server that does both, tested with curl, followed by the Docker and Caddy steps to put it on a VPS behind HTTPS.
Disclosure: the Hostinger link is an affiliate link. The server code below ran locally on 23 Oct 2026 with mcp 2.3.0, and the curl outputs are real. The Docker and Caddy parts are unrun here. I did not connect a real MCP client to it.
If you've followed the MCP server tutorial, you have a server that runs over stdio on your own machine. That's fine until you want your phone, a teammate, or a hosted client to use it. Then it needs a public URL, and the moment it has one, security stops being optional. The MCP security checklist covers the permissions side; this post is the deployment side.
What does the spec say a remote server must do?
From the 2025-06-18 transports page:
- The server provides a single HTTP endpoint path that supports both POST and GET.
- Servers MUST validate the
Originheader on all incoming connections to prevent DNS rebinding attacks. - When running locally, servers SHOULD bind only to localhost rather than all network interfaces.
- Servers SHOULD implement proper authentication for all connections.
- Clients send every JSON-RPC message as a new POST and must list both
application/jsonandtext/event-streaminAccept. - Streamable HTTP replaced the HTTP+SSE transport from 2024-11-05.
A server may also hand out an Mcp-Session-Id header at initialization, and then clients must send it on every later request.
How do you write the server?
I installed the Python SDK in a fresh virtual environment (pip install mcp, which gave me version 2.3.0 on 23 Oct 2026). Gotcha number one: if your old code says from mcp.server.fastmcp import FastMCP, it fails on 2.x with a ModuleNotFoundError that says FastMCP was renamed to MCPServer. Use the new import or pin mcp<2.
import hmac
import os
from mcp.server.mcpserver import MCPServer
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
mcp = MCPServer("notes-tools")
@mcp.tool()
def word_count(text: str) -> int:
"""Count the words in a piece of text."""
return len(text.split())
TOKEN = os.environ["MCP_TOKEN"]
ALLOWED_ORIGINS = {o for o in os.environ.get("ALLOWED_ORIGINS", "").split(",") if o}
class Guard(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
if request.url.path == "/healthz":
return JSONResponse({"ok": True})
origin = request.headers.get("origin")
if origin and origin not in ALLOWED_ORIGINS:
return JSONResponse({"error": "origin not allowed"}, status_code=403)
supplied = request.headers.get("authorization", "")
expected = f"Bearer {TOKEN}"
if not hmac.compare_digest(supplied.encode(), expected.encode()):
return JSONResponse({"error": "unauthorized"}, status_code=401)
return await call_next(request)
app = mcp.streamable_http_app()
app.add_middleware(Guard)
Save it as server.py. The guard does two jobs. If a request carries an Origin header that isn't on your allow-list, it returns 403. A browser-based attacker page sends one, while a plain server-to-server client usually doesn't. Everything else must present the right bearer token, compared in constant time. A static token is a floor, not a ceiling: if other people's clients will connect, read the MCP OAuth explainer.
The /healthz route is intentionally open so Docker and Caddy can check it. The /mcp path is the default endpoint of the SDK's Streamable HTTP app, and the real responses below came from it.
Does it work? Test with curl
I ran it with MCP_TOKEN=testtoken123 python -m uvicorn server:app --host 127.0.0.1 --port 8765, then:
No token:
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:8765/mcp \
-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}'
401
A foreign Origin header with a valid token returned 403. With the right token, initialize returned:
event: message
data: {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"prompts":{"listChanged":false},"resources":{"listChanged":false,"subscribe":false},"tools":{"listChanged":false}},"protocolVersion":"2025-06-18","serverInfo":{"name":"notes-tools","version":""}}}
Calling the tool without a session gave Bad Request: Missing session ID. The server did send an Mcp-Session-Id response header on initialize; I passed it back, along with MCP-Protocol-Version: 2025-06-18, and called the tool:
event: message
data: {"jsonrpc":"2.0","id":2,"result":{"content":[{"text":"3","type":"text"}],"isError":false,"structuredContent":{"result":3}}}
word_count on "one two three" returned 3. I did not test streaming, resumability, the GET stream or session expiry.
How do you put it on a VPS?
A one-core plan is enough for a server this small, though your tools decide the real memory needs (see which plan to buy and measure with docker stats). Unrun, but standard:
# Dockerfile
FROM python:3.13-slim
WORKDIR /app
RUN pip install --no-cache-dir "mcp>=2,<3" uvicorn
COPY server.py .
CMD ["python", "-m", "uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"]
# docker-compose.yml
services:
mcp:
build: .
restart: unless-stopped
environment:
MCP_TOKEN: ${MCP_TOKEN}
ALLOWED_ORIGINS: ""
ports:
- "127.0.0.1:8000:8000"
Binding the published port to 127.0.0.1 means the only public path is the reverse proxy. Put the token in a .env file next to the compose file, generated with openssl rand -hex 32, and keep that file out of git.
Caddy then terminates TLS (point a DNS record at the server and keep ports 80 and 443 open, per Caddy's docs):
mcp.example.com {
reverse_proxy localhost:8000
}
Your connector URL becomes https://mcp.example.com/mcp. If you're choosing a proxy, the nginx vs Caddy vs Traefik comparison has the trade-offs. Streaming responses can be buffered by some proxy defaults, so test a long-running tool through the proxy rather than assuming.
What should you not expose?
- Tools that run shell commands, write files or send money, unless every caller is you.
- The same token on multiple clients. Use one per client so you can revoke one.
- Secrets for downstream services in tool output. The model will read whatever the tool returns.
Harden the box as well: key-only SSH, a firewall allowing 22, 80 and 443, and updates (the hardening checklist). Need a server? Hostinger KVM 1 or KVM 2 both fit a small server like this.



