Implement Remote MCP Servers and Secure Tunnels
Implement remote Model Context Protocol servers exposed via HTTPS and SSE tunnels with bearer token authentication and reverse proxies.
Connecting Large Language Models like Claude to local tools typically relies on standard input and output (stdio) subprocesses. While stdio works seamlessly for single-user desktop workflows, production engineering environments quickly demand remote multi-tenant tool hubs. When our team needs centralized database diagnostic tools, cloud deployment triggers, or unified data connectors shared across multiple agent runtimes, running isolated local processes on every developer machine is impractical.
In this guide, we walk through implementing production-grade remote MCP servers over HTTP and SSE. We configure authentication middleware, tune Nginx reverse proxy buffering, deploy zero-trust edge tunnels with Cloudflare and Tailscale, and verify the deployment using automated sub-second cURL probes.
Remote MCP Architecture Overview
The Model Context Protocol specification supports both Stdio and HTTP/SSE transports. When operating over HTTP, the client and server maintain a split-channel architecture:
- The Downstream Stream (
GET /sse): The client initiates a persistent HTTP connection requestingtext/event-stream. The server accepts the connection, assigns an ephemeralsessionId, and streams initial endpoint metadata and asynchronous notifications to the client. - The Upstream Message Channel (
POST /messages/): The client sends JSON-RPC 2.0 requests (such astools/listortools/call) as discrete HTTP POST payloads targeting the message endpoint, identifying itself viasessionIdquery parameters or headers. - Session Lifecycle Management: The server maps responses from the upstream POST requests back to the active SSE streaming transport for delivery back to the client.
[Claude Client / Agent] │ │ 1. GET /sse (Authorization: Bearer ) ├─────────────────────────────────────────────────┐ │ │ ▼ ▼[Reverse Proxy / Edge Tunnel] [Reverse Proxy / Edge Tunnel](Cloudflare / Tailscale / Nginx) (Cloudflare / Tailscale / Nginx) │ │ │ proxy_buffering off; │ Standard POST │ │ ▼ ▼[Remote MCP Server: /sse] [Remote MCP Server: /messages/](Maintains active Session ID map) (Routes JSON-RPC into Session)For a thorough grounding in transport mechanics, see our previous guide on configuring stdio and sse mcp transports.
Prerequisites and Environment Setup
Before implementing remote servers, ensure your development environment satisfies the following requirements:
- Python 3.10+ or Node.js 18+ with TypeScript.
- Python MCP SDK (
pip install "mcp>=1.2.0" starlette uvicorn) or TypeScript MCP SDK (npm install @modelcontextprotocol/sdk express cors dotenv). - A Linux host (Ubuntu 22.04+ or Debian 12+) with Nginx installed for reverse proxy testing.
- An existing Cloudflare account with
cloudflaredinstalled, or a Tailscale network configured for Tailscale Funnel. - A high-entropy bearer token secret (minimum 32 characters).
Initialize your configuration using an environment file:
# Clone the recipe repositorygit clone https://github.com/zeroshotstudio/zerolabs-recipes.gitcd zerolabs-recipes/recipes/claude/26-remote-mcp-servers-and-tunnels# Prepare environment variablescp .env.example .envGenerate a secure token for production use:
openssl rand -hex 32Add the generated secret to your .env file under MCP_AUTH_TOKEN.
Implementing the Python Remote MCP Server
The Python MCP SDK provides the FastMCP interface along with lower-level SseServerTransport primitives. To expose an authenticated remote server, we wrap Starlette around the transport, validating incoming requests using constant-time string comparisons to prevent timing side-channel attacks.
Here is the complete implementation in Python:
#!/usr/bin/env python3"""Remote Model Context Protocol (MCP) Server with Bearer Authentication & SSE.Designed for secure reverse proxy deployment behind Nginx, Cloudflare Tunnels, or Tailscale Funnel."""import osimport secretsfrom typing import Dictfrom starlette.applications import Starlettefrom starlette.requests import Requestfrom starlette.responses import JSONResponse, Responsefrom starlette.middleware import Middlewarefrom starlette.middleware.cors import CORSMiddlewarefrom starlette.routing import Routeimport uvicornfrom mcp.server.fastmcp import FastMCPfrom mcp.server.sse import SseServerTransportAUTH_TOKEN = os.getenv("MCP_AUTH_TOKEN", "default-insecure-dev-token-change-in-production")PORT = int(os.getenv("MCP_PORT", "8000"))HOST = os.getenv("MCP_HOST", "0.0.0.0")# Initialize FastMCP application instancemcp = FastMCP("ProductionRemoteOps")@mcp.tool()def query_system_metrics() -> dict: """Retrieve operational health metrics from the remote server environment.""" return { "status": "healthy", "load_average": [0.12, 0.08, 0.05], "uptime_seconds": 86400, "active_tenants": 4, "protocol_version": "2024-11-05" }@mcp.tool()def execute_database_diagnostics(cluster_id: str) -> dict: """Run read-only database cluster connection diagnostics.""" return { "cluster_id": cluster_id, "connection_pool": {"active": 12, "idle": 28, "max": 50}, "replication_lag_ms": 1.4, "status": "synchronized" }# Active SSE Transports indexed by Session IDactive_transports: Dict[str, SseServerTransport] = {}def verify_token(authorization_header: str | None) -> bool: """Constant-time bearer token validation to prevent timing attacks.""" if not authorization_header or not authorization_header.startswith("Bearer "): return False incoming_token = authorization_header[len("Bearer "):].strip() return secrets.compare_digest(incoming_token, AUTH_TOKEN)async def handle_sse(request: Request) -> Response: """Handle SSE connection initiation and register session.""" auth_header = request.headers.get("Authorization") if not verify_token(auth_header): return JSONResponse({"error": "Unauthorized", "message": "Valid Bearer token required"}, status_code=401) transport = SseServerTransport("/messages/") session_id = secrets.token_urlsafe(16) active_transports[session_id] = transport async with transport.connect_sse(request.scope, request.receive, request._send) as streams: await mcp._mcp_server.run(streams[0], streams[1], mcp._mcp_server.create_initialization_options()) active_transports.pop(session_id, None) return Response(status_code=200)async def handle_messages(request: Request) -> Response: """Handle incoming JSON-RPC client messages routed to active SSE session.""" auth_header = request.headers.get("Authorization") if not verify_token(auth_header): return JSONResponse({"error": "Unauthorized", "message": "Valid Bearer token required"}, status_code=401) session_id = request.query_params.get("sessionId") or request.headers.get("Mcp-Session-Id") if not session_id or session_id not in active_transports: return JSONResponse({"error": "Bad Request", "message": "Invalid or missing sessionId"}, status_code=400) transport = active_transports[session_id] await transport.handle_post_message(request.scope, request.receive, request._send) return Response(status_code=202)async def handle_health(request: Request) -> Response: """Health check endpoint for reverse proxy probes.""" return JSONResponse({ "status": "online", "service": "remote-mcp-server", "active_sessions": len(active_transports) })routes = [ Route("/sse", endpoint=handle_sse, methods=["GET"]), Route("/messages/", endpoint=handle_messages, methods=["POST"]), Route("/health", endpoint=handle_health, methods=["GET"])]middleware = [ Middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["GET", "POST", "OPTIONS"], allow_headers=["*"], expose_headers=["Mcp-Session-Id"] )]app = Starlette(routes=routes, middleware=middleware)if __name__ == "__main__": uvicorn.run(app, host=HOST, port=PORT, log_level="info")For more details on writing Python MCP tool handlers and schemas, review our guide on how to build an mcp server with python sdk.
Implementing the TypeScript Remote MCP Server
In enterprise Node.js environments, you can implement the equivalent remote server using Express and @modelcontextprotocol/sdk. The TypeScript SDK provides SSEServerTransport, which manages the SSE connection and response lifecycle directly.
Here is the TypeScript implementation:
import express, { Request, Response, NextFunction } from "express";import cors from "cors";import crypto from "crypto";import { Server } from "@modelcontextprotocol/sdk/server/index.js";import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";import { CallToolRequestSchema, ListToolsRequestSchema,} from "@modelcontextprotocol/sdk/types.js";const PORT = parseInt(process.env.MCP_PORT || "8000", 10);const AUTH_TOKEN = process.env.MCP_AUTH_TOKEN || "default-insecure-dev-token-change-in-production";const mcpServer = new Server( { name: "ProductionRemoteOpsTS", version: "1.0.0", }, { capabilities: { tools: {}, }, });mcpServer.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "query_system_metrics", description: "Retrieve operational health metrics from the remote server environment.", inputSchema: { type: "object", properties: {}, }, }, { name: "execute_database_diagnostics", description: "Run read-only database cluster connection diagnostics.", inputSchema: { type: "object", properties: { cluster_id: { type: "string", description: "Target database cluster identifier" }, }, required: ["cluster_id"], }, }, ], };});mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; if (name === "query_system_metrics") { return { content: [ { type: "text", text: JSON.stringify({ status: "healthy", load_average: [0.12, 0.08, 0.05], uptime_seconds: 86400, active_tenants: 4, protocol_version: "2024-11-05", }), }, ], }; } if (name === "execute_database_diagnostics") { const clusterId = args?.cluster_id as string; return { content: [ { type: "text", text: JSON.stringify({ cluster_id: clusterId, connection_pool: { active: 12, idle: 28, max: 50 }, replication_lag_ms: 1.4, status: "synchronized", }), }, ], }; } throw new Error(`Tool not found: ${name}`);});const activeTransports = new Map();const app = express();app.use(cors({ origin: "*", exposedHeaders: ["Mcp-Session-Id"] }));const authenticateBearer = (req: Request, res: Response, next: NextFunction): void => { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith("Bearer ")) { res.status(401).json({ error: "Unauthorized", message: "Valid Bearer token required" }); return; } const token = authHeader.slice("Bearer ".length).trim(); const tokenBuffer = Buffer.from(token); const secretBuffer = Buffer.from(AUTH_TOKEN); if (tokenBuffer.length !== secretBuffer.length || !crypto.timingSafeEqual(tokenBuffer, secretBuffer)) { res.status(401).json({ error: "Unauthorized", message: "Invalid Bearer token" }); return; } next();};app.get("/health", (_req: Request, res: Response) => { res.json({ status: "online", service: "remote-mcp-server-ts", active_sessions: activeTransports.size, });});app.get("/sse", authenticateBearer, async (req: Request, res: Response) => { const transport = new SSEServerTransport("/messages", res); const sessionId = transport.sessionId; activeTransports.set(sessionId, transport); res.setHeader("Mcp-Session-Id", sessionId); req.on("close", () => { activeTransports.delete(sessionId); }); await mcpServer.connect(transport);});app.post("/messages", authenticateBearer, async (req: Request, res: Response) => { const sessionId = (req.query.sessionId as string) || (req.headers["mcp-session-id"] as string); if (!sessionId || !activeTransports.has(sessionId)) { res.status(400).json({ error: "Bad Request", message: "Invalid or expired sessionId" }); return; } const transport = activeTransports.get(sessionId)!; await transport.handlePostMessage(req, res);});app.listen(PORT, "0.0.0.0", () => { console.log(`Production Remote MCP Server (TS) listening on http://0.0.0.0:${PORT}`);});To dive deeper into TypeScript server creation and type-safe schemas, refer to how to build an mcp server with typescript sdk.
Nginx Reverse Proxy Configuration for SSE
When deploying remote MCP servers behind a traditional web server or reverse proxy, standard proxy configurations often break Server-Sent Events. Nginx buffers downstream responses by default, meaning event packets accumulate in memory rather than streaming in real time.
To resolve this, apply the following directives to your Nginx configuration:
# Rate limiting zone for SSE endpoint (prevents connection exhaustion)limit_req_zone $binary_remote_addr zone=mcp_limit:10m rate=30r/s;upstream mcp_backend { server 127.0.0.1:8000; keepalive 32;}server { listen 443 ssl http2; server_name mcp.yourdomain.com; ssl_certificate /etc/letsencrypt/live/mcp.yourdomain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/mcp.yourdomain.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; client_max_body_size 16M; add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "DENY" always; add_header Referrer-Policy "no-referrer-when-downgrade" always; location / { limit_req zone=mcp_limit burst=50 nodelay; proxy_pass http://mcp_backend; proxy_http_version 1.1; # Mandatory SSE and Streaming Directives proxy_buffering off; proxy_cache off; proxy_set_header Connection ''; chunked_transfer_encoding off; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # Forward MCP Session & Auth headers proxy_pass_header Authorization; proxy_pass_header Mcp-Session-Id; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }}Key Nginx configuration points:
proxy_buffering off;: Ensures events flush immediately to the client socket without buffering.proxy_set_header Connection '';: Clears the defaultConnection: closeheader, maintaining the HTTP/1.1 persistent connection.chunked_transfer_encoding off;: Prevents unnecessary transfer chunk framing around raw SSE data lines.proxy_read_timeout 3600s;: Prevents gateway timeouts on idle or long-lived SSE connections.
Secure Tunneling with Cloudflare Tunnels and Tailscale Funnel
Exposing ports directly to the public internet introduces unnecessary attack surfaces. In production, we deploy zero-trust edge tunnels that establish outbound encrypted tunnels without requiring open inbound firewall ports.
Option A: Cloudflare Tunnels (Quick and Secure Edge Ingress)
Cloudflare Tunnels connect your local or private network service to Cloudflare edge infrastructure.
-
Install and Authenticate cloudflared:
curl -L --output cloudflared.deb https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.debsudo dpkg -i cloudflared.debcloudflared tunnel login -
Create the Tunnel:
cloudflared tunnel create mcp-production-server -
Configure Routing (
~/.cloudflared/config.yml):tunnel: credentials-file: /root/.cloudflared/.jsoningress: - hostname: mcp.yourdomain.com service: http://127.0.0.1:8000 originRequest: noTLSVerify: false keepAliveTimeout: 120s - service: http_status:404 -
Route DNS and Run as a Service:
cloudflared tunnel route dns mcp-production-server mcp.yourdomain.comsudo cloudflared service installsudo systemctl start cloudflared
Option B: Tailscale Funnel (Direct Node-to-Node Ingress)
If your infrastructure runs on Tailscale, Tailscale Funnel allows routing public internet traffic directly to an internal node:
-
Enable Tailscale Funnel:
sudo tailscale funnel 8000 on -
Verify Public Status:
tailscale funnel statusTailscale provides an encrypted public URL (e.g.,
https://node-name.tailnet-alias.ts.net) with automatic TLS termination and direct proxying to port 8000.
Verifying Deployment with Automated Probes
To ensure your remote MCP server, proxy buffering, and authentication rules operate correctly, use an automated verification script.
Here is our test script, available in the repository as curl_probe.sh:
#!/usr/bin/env bashset -euo pipefailTARGET_URL="${1:-http://127.0.0.1:8000}"TOKEN="${2:-default-insecure-dev-token-change-in-production}"echo "Testing Health Endpoint..."curl -sf "${TARGET_URL}/health" | jq .echo "Verifying Unauthorized SSE Rejection..."STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${TARGET_URL}/sse")if [ "$STATUS" -eq 401 ]; then echo "PASS: HTTP 401 Unauthorized received."else echo "FAIL: Expected 401, received $STATUS" exit 1fiecho "Testing Authenticated SSE Stream Handshake..."TMP_OUT=$(mktemp)curl -s -N -m 3 -H "Authorization: Bearer ${TOKEN}" "${TARGET_URL}/sse" > "${TMP_OUT}" 2>/dev/null || trueif grep -q "endpoint" "${TMP_OUT}" || grep -q "event:" "${TMP_OUT}"; then echo "PASS: SSE stream initiated successfully." head -n 5 "${TMP_OUT}"else echo "FAIL: No SSE payload received." cat "${TMP_OUT}" rm -f "${TMP_OUT}" exit 1firm -f "${TMP_OUT}"Run the probe against your local server or tunnel endpoint:
./curl_probe.sh https://mcp.yourdomain.com your-auth-tokenExpected output:
Testing Health Endpoint...{ "status": "online", "service": "remote-mcp-server", "active_sessions": 0}Verifying Unauthorized SSE Rejection...PASS: HTTP 401 Unauthorized received.Testing Authenticated SSE Stream Handshake...PASS: SSE stream initiated successfully.event: endpointdata: /messages/?sessionId=71b9c8...Connecting Claude Desktop and Agent Clients
To wire Claude Desktop or external agent runtimes to your remote server, update your client configuration (e.g., claude_desktop_config.json):
{ "mcpServers": { "remote-ops": { "url": "https://mcp.yourdomain.com/sse", "headers": { "Authorization": "Bearer your-high-entropy-shared-bearer-token-secret-min32chars" } } }}When Claude connects, the client initiates the /sse handshake, receives the dynamic message endpoint with session ID, and routes tool calls over HTTPS with bearer authorization headers attached to every payload.
To review the underlying specification and protocol schema, consult the official Model Context Protocol documentation.
Conclusion and Production Checklist
Remote MCP servers unlock centralized agent capabilities across distributed teams. Before deploying to production, verify each item on this checklist:
- Authentication: All
/sseand/messages/requests enforce bearer token validation using constant-time comparison. - Reverse Proxy Buffering: Nginx or your gateway disables proxy buffering (
proxy_buffering off;) to maintain live event streams. - Transport Security: All external traffic flows through TLS 1.2+ with HSTS enabled, either via Nginx or managed edge tunnels (Cloudflare, Tailscale).
- Session Cleanup: The server registers teardown listeners on socket close events to prune expired sessions and avoid memory leaks.
- Rate Limiting: Downstream connection endpoints include rate limiting to protect against connection exhaustion and denial of service.
The complete code, Nginx configurations, and automated probe scripts are available in the ZeroLabs Recipes repository.