Build and Self-Host a Custom MCP Server on a VPS

11 min read·Matthieu·MCPTypeScriptNode.jsNginxSelf-hostingAI Agents|

Build a TypeScript MCP server from scratch, deploy it on a VPS with systemd, and run it behind Nginx with TLS. Connect Claude Desktop, Claude Code, and Cursor to your self-hosted server.

Every MCP server tutorial stops at "run locally with stdio." This one covers the full path: build a TypeScript MCP server, deploy it on a VPS behind Nginx with TLS, lock it down with authentication and firewall rules, and connect MCP clients over the internet.

By the end, you will have a production MCP server that Claude Desktop, Claude Code, Cursor, or any MCP-compatible client can reach remotely.

What will you build?

MCP clients send JSON-RPC requests over HTTPS. Nginx terminates TLS and proxies requests to your MCP server process running on a local port. The MCP server validates a bearer token, processes the request, and returns results.

MCP Client (Claude, Cursor, ...)
        |
        | HTTPS (port 443)
        v
   Nginx (TLS termination + reverse proxy)
        |
        | HTTP (port 3000, localhost only)
        v
   MCP Server (Node.js + Express)
        |
        v
   SQLite database

The working example is a notes server. It exposes two tools (search notes, create note) and one resource (list all notes). Simple enough to follow, useful enough to extend.

What transport should you use for a remote MCP server?

Streamable HTTP is the current MCP specification standard for remote deployments. It replaced the deprecated SSE transport and works over regular HTTP POST requests. Stdio remains the transport for local-only servers where the client launches the server as a subprocess.

Transport Use case Remote capable Client support
stdio Local tools, CLI integrations No All clients
SSE (deprecated) Legacy remote servers Yes Most clients (being phased out)
Streamable HTTP Remote servers, production Yes Claude Desktop, Claude Code, Cursor

Streamable HTTP sends each client message as an HTTP POST to a single endpoint (e.g., /mcp). The server responds with application/json for simple replies or text/event-stream when it needs to stream multiple messages back. This means it works with standard HTTP infrastructure, including reverse proxies and load balancers.

Prerequisites

  • A VPS running Debian 12 or Ubuntu 24.04 (this guide uses Debian 12)
  • A domain name with DNS pointing to your VPS (e.g., mcp.example.com)
  • Node.js 20+ installed on the VPS
  • Basic familiarity with TypeScript and Linux

[-> self-host-ai-agents-vps]

How do you set up a TypeScript MCP server project?

Start on your local machine or directly on the VPS. Initialize the project, install the MCP SDK, and set up TypeScript.

mkdir mcp-notes-server && cd mcp-notes-server
npm init -y
npm install @modelcontextprotocol/sdk zod express better-sqlite3
npm install -D typescript @types/node @types/express @types/better-sqlite3

Create the TypeScript config:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./build",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

Save this as tsconfig.json in the project root.

Update package.json to add the build and start scripts and set the module type:

{
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "node build/index.js"
  }
}

Create the source directory:

mkdir src

How do you implement MCP tools with input validation?

An MCP server exposes tools, resources, and prompts to AI clients. Tools are functions the AI can call. Resources are read-only data the AI can reference. We will implement both.

Create src/index.ts with the database setup and a factory function that builds a fresh MCP server for each request. Stateless Streamable HTTP requires a new McpServer instance per request because the SDK does not allow reconnecting the same server to a different transport.

import express from "express";
import { randomUUID } from "crypto";
import Database from "better-sqlite3";
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";

// --- Database setup ---

const db = new Database("notes.db");
db.pragma("journal_mode = WAL");
db.exec(`
  CREATE TABLE IF NOT EXISTS notes (
    id TEXT PRIMARY KEY,
    title TEXT NOT NULL,
    content TEXT NOT NULL,
    created_at TEXT DEFAULT (datetime('now'))
  )
`);

// --- MCP Server factory ---
// Each stateless request needs its own McpServer + transport pair.
// The SDK binds a server to a single transport on connect() and
// throws if you try to reuse it. A factory function keeps it clean.

function createServer(): McpServer {
  const server = new McpServer({
    name: "notes-server",
    version: "1.0.0",
  });

Now register the tools inside the factory. Each tool uses Zod for input validation. The SDK validates inputs automatically before your handler runs.

  // --- Tools ---

  server.tool(
    "search_notes",
    "Search notes by keyword in title or content",
    {
      query: z.string().min(1).describe("Search keyword"),
    },
    async ({ query }) => {
      const stmt = db.prepare(
        "SELECT id, title, content, created_at FROM notes WHERE title LIKE ? OR content LIKE ? ORDER BY created_at DESC LIMIT 20"
      );
      const rows = stmt.all(`%${query}%`, `%${query}%`);
      return {
        content: [
          {
            type: "text" as const,
            text: JSON.stringify(rows, null, 2),
          },
        ],
      };
    }
  );

  server.tool(
    "create_note",
    "Create a new note with a title and content",
    {
      title: z.string().min(1).max(200).describe("Note title"),
      content: z.string().min(1).describe("Note content"),
    },
    async ({ title, content }) => {
      const id = randomUUID();
      const stmt = db.prepare(
        "INSERT INTO notes (id, title, content) VALUES (?, ?, ?)"
      );
      stmt.run(id, title, content);
      return {
        content: [
          {
            type: "text" as const,
            text: `Note created with id: ${id}`,
          },
        ],
      };
    }
  );

Every input gets validated by Zod before reaching the handler. If a client sends { query: "" }, the SDK returns a validation error automatically. No manual parsing needed.

How do you add resources to an MCP server?

Resources give AI clients read-only access to data. They are referenced by URI. Add a resource that lists all notes, still inside the createServer factory:

  // --- Resources ---

  server.resource(
    "all-notes",
    "notes://all",
    {
      description: "List all notes in the database",
      mimeType: "application/json",
    },
    async (uri) => {
      const rows = db.prepare(
        "SELECT id, title, created_at FROM notes ORDER BY created_at DESC LIMIT 100"
      ).all();
      return {
        contents: [
          {
            uri: uri.href,
            mimeType: "application/json",
            text: JSON.stringify(rows, null, 2),
          },
        ],
      };
    }
  );

  return server;
}

MCP clients can discover this resource and read it without invoking a tool. Claude Desktop shows resources in the attachment menu. Claude Code exposes them through @ mentions.

How do you secure an MCP server with token authentication?

A production MCP server needs authentication. Without it, anyone who discovers your endpoint can invoke your tools. Use a bearer token checked in Express middleware before requests reach the MCP transport.

Generate a strong token:

openssl rand -base64 32

Save the output. You will need it for both the server config and client config.

Add the authentication middleware and Express routes to src/index.ts:

// --- Auth middleware ---

const AUTH_TOKEN = process.env.MCP_AUTH_TOKEN;
if (!AUTH_TOKEN) {
  console.error("MCP_AUTH_TOKEN environment variable is required");
  process.exit(1);
}

function authenticateRequest(
  req: express.Request,
  res: express.Response,
  next: express.NextFunction
): void {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith("Bearer ")) {
    res.status(401).json({
      jsonrpc: "2.0",
      error: { code: -32001, message: "Missing or invalid authorization" },
      id: null,
    });
    return;
  }
  const token = authHeader.slice(7);
  if (token !== AUTH_TOKEN) {
    res.status(403).json({
      jsonrpc: "2.0",
      error: { code: -32001, message: "Invalid token" },
      id: null,
    });
    return;
  }
  next();
}

// --- Express app ---

const app = express();
app.use(express.json());
app.use("/mcp", authenticateRequest);

The middleware runs before every request to /mcp. It checks for a Bearer token in the Authorization header and rejects unauthorized requests with a JSON-RPC error response.

How do you set up Streamable HTTP transport?

Add the transport setup and route handlers. Each request creates a fresh McpServer from the factory and a new transport instance:

// --- Streamable HTTP transport ---

app.post("/mcp", async (req, res) => {
  const server = createServer();
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
  });
  await server.connect(transport);
  try {
    await transport.handleRequest(req, res, req.body);
  } catch (error) {
    console.error("MCP request error:", error);
    if (!res.headersSent) {
      res.status(500).json({
        jsonrpc: "2.0",
        error: { code: -32603, message: "Internal server error" },
        id: null,
      });
    }
  }
});

app.get("/mcp", (_req, res) => {
  res.status(405).json({
    jsonrpc: "2.0",
    error: { code: -32000, message: "Method not allowed. Use POST." },
    id: null,
  });
});

app.delete("/mcp", (_req, res) => {
  res.status(405).json({
    jsonrpc: "2.0",
    error: { code: -32000, message: "Method not allowed." },
    id: null,
  });
});

// --- Health check ---

app.get("/health", (_req, res) => {
  res.json({ status: "ok" });
});

// --- Start ---

const PORT = parseInt(process.env.PORT || "3000", 10);
app.listen(PORT, "127.0.0.1", () => {
  console.log(`MCP server listening on http://127.0.0.1:${PORT}/mcp`);
});

Setting sessionIdGenerator: undefined creates a stateless server. Each request is independent, with its own McpServer and transport pair. This is simpler to deploy and works well behind a reverse proxy with no sticky sessions needed. The database connection (db) lives at module scope and is shared across requests, so data persists between calls.

The server binds to 127.0.0.1, not 0.0.0.0. It only accepts connections from localhost. Nginx will be the only way to reach it from outside.

Build and test locally:

npm run build

Verify the build succeeds with no errors.

How do you test an MCP server locally?

The MCP Inspector is the official testing tool. It connects to your server and lets you invoke tools interactively.

Start your server in one terminal:

MCP_AUTH_TOKEN=test-token-local npm start

In another terminal, test with curl:

curl -X POST http://127.0.0.1:3000/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "Authorization: Bearer test-token-local" \
  -d '{
    "jsonrpc": "2.0",
    "method": "initialize",
    "params": {
      "protocolVersion": "2025-03-26",
      "capabilities": {},
      "clientInfo": { "name": "test-client", "version": "1.0.0" }
    },
    "id": 1
  }'

You should see an SSE response (event: message followed by a data: line) containing the server's capabilities, including the tools and resources objects. This confirms the server accepts connections and responds to the MCP initialization handshake.

Test the create_note tool:

curl -X POST http://127.0.0.1:3000/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "Authorization: Bearer test-token-local" \
  -d '{
    "jsonrpc": "2.0",
    "method": "tools/call",
    "params": {
      "name": "create_note",
      "arguments": { "title": "Test Note", "content": "Hello from curl" }
    },
    "id": 2
  }'

The response should contain "Note created with id: ...". Now test the search tool:

curl -X POST http://127.0.0.1:3000/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "Authorization: Bearer test-token-local" \
  -d '{
    "jsonrpc": "2.0",
    "method": "tools/call",
    "params": {
      "name": "search_notes",
      "arguments": { "query": "Test" }
    },
    "id": 3
  }'

You should see the note you just created in the results. Verify that sending a request without the Authorization header returns a 401 error:

curl -s -o /dev/null -w "%{http_code}" -X POST http://127.0.0.1:3000/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"initialize","params":{},"id":1}'

This should print 401. If it does, your auth middleware is working.

You can also use the MCP Inspector for interactive testing with a web UI:

npx @modelcontextprotocol/inspector

The Inspector opens a browser interface where you can connect to your server, browse tools and resources, and test invocations.

How do you deploy an MCP server to a VPS?

Transfer your project to the VPS using rsync, scp, or push to a Git repo and clone on the server. The following steps assume you are logged into the VPS.

Create a dedicated user

Never run application processes as root. Create a service user:

sudo useradd -r -m -s /usr/sbin/nologin mcpserver

Install Node.js

If Node.js 20+ is not already installed:

curl -fsSL https://deb.nodesource.com/setup_20.x -o nodesource_setup.sh
sudo bash nodesource_setup.sh
sudo apt-get install -y nodejs
node --version
npm --version

The first command downloads the setup script to a file so you can inspect it before running. Verify the output shows v20.x.x or higher for Node.js and 10.x.x for npm. The MCP SDK requires Node.js 18+ but 20 LTS is recommended for production.

Set up the project directory

sudo mkdir -p /opt/mcp-notes-server
sudo cp -r /path/to/your/project/* /opt/mcp-notes-server/
cd /opt/mcp-notes-server
sudo npm ci --omit=dev
sudo npm run build
sudo chown -R mcpserver:mcpserver /opt/mcp-notes-server

Verify ownership:

ls -la /opt/mcp-notes-server/

All files should be owned by mcpserver:mcpserver.

Create the environment file

Store the auth token in a restricted environment file:

sudo mkdir -p /etc/mcp-notes-server
echo "MCP_AUTH_TOKEN=$(openssl rand -base64 32)" | sudo tee /etc/mcp-notes-server/env > /dev/null
sudo chmod 600 /etc/mcp-notes-server/env
sudo chown mcpserver:mcpserver /etc/mcp-notes-server/env

Read the generated token and save it somewhere secure. You will need it for client configuration:

sudo cat /etc/mcp-notes-server/env

Create the systemd service

sudo tee /etc/systemd/system/mcp-notes-server.service > /dev/null << 'EOF'
[Unit]
Description=MCP Notes Server
After=network.target

[Service]
Type=simple
User=mcpserver
Group=mcpserver
WorkingDirectory=/opt/mcp-notes-server
EnvironmentFile=/etc/mcp-notes-server/env
ExecStart=/usr/bin/node build/index.js
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal

# Hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/mcp-notes-server
PrivateTmp=true

[Install]
WantedBy=multi-user.target
EOF

EnvironmentFile loads MCP_AUTH_TOKEN from the restricted file. The hardening section limits what the process can do. NoNewPrivileges prevents privilege escalation. ProtectSystem=strict makes the filesystem read-only except for paths listed in ReadWritePaths. ProtectHome blocks access to user home directories.

Enable and start the service:

sudo systemctl enable --now mcp-notes-server

enable makes the service start on boot. --now starts it immediately.

Check the status:

sudo systemctl status mcp-notes-server

You should see active (running). If the service failed, check the logs:

journalctl -u mcp-notes-server -n 50 --no-pager

Verify the server responds locally:

TOKEN=$(sudo grep MCP_AUTH_TOKEN /etc/mcp-notes-server/env | cut -d= -f2)
curl -s http://127.0.0.1:3000/health

This should return {"status":"ok"}.

How do you run an MCP server behind Nginx with TLS?

Nginx terminates TLS and proxies requests to the MCP server. The MCP server never handles TLS directly. This is the standard pattern for production Node.js deployments.

[-> nginx-reverse-proxy]

Install Nginx and Certbot

sudo apt update
sudo apt install -y nginx certbot python3-certbot-nginx

Configure the Nginx server block

sudo tee /etc/nginx/sites-available/mcp.example.com.conf > /dev/null << 'NGINX'
server {
    listen 80;
    listen [::]:80;
    server_name mcp.example.com;

    # Certbot will add the redirect to HTTPS
    location / {
        return 444;
    }
}
NGINX

Replace mcp.example.com with your actual domain.

Enable the site and obtain a TLS certificate:

sudo ln -s /etc/nginx/sites-available/mcp.example.com.conf /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
sudo certbot --nginx -d mcp.example.com

Certbot modifies the config to add the TLS settings and an HTTP-to-HTTPS redirect. Now update the config to add the reverse proxy for the MCP endpoint:

sudo tee /etc/nginx/sites-available/mcp.example.com.conf > /dev/null << 'NGINX'
server {
    listen 80;
    listen [::]:80;
    server_name mcp.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name mcp.example.com;

    ssl_certificate /etc/letsencrypt/live/mcp.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/mcp.example.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

    # Hide Nginx version
    server_tokens off;

    # MCP endpoint
    location /mcp {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;

        # Required for Streamable HTTP / SSE responses
        proxy_buffering off;
        proxy_cache off;
        chunked_transfer_encoding off;

        # Keep connection open for streaming responses
        proxy_set_header Connection "";

        # Extended timeouts for long-running tool calls
        proxy_read_timeout 300s;
        proxy_send_timeout 300s;

        # Forward client info
        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;

        # Pass through Authorization header
        proxy_set_header Authorization $http_authorization;
    }

    # Health check (no auth required)
    location /health {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
    }

    # Block everything else
    location / {
        return 444;
    }
}
NGINX

The key directives for MCP compatibility:

  • proxy_buffering off prevents Nginx from buffering SSE responses. Without it, streaming responses hang until the connection closes.
  • proxy_cache off ensures no caching of dynamic responses.
  • chunked_transfer_encoding off avoids chunking issues with SSE event streams.
  • Connection "" keeps the connection alive without triggering WebSocket upgrade behavior.
  • proxy_read_timeout 300s allows tool calls that take up to 5 minutes to complete. The default 60s is too short for complex operations.

[-> nginx-ssl-tls-lets-encrypt]

Test and reload:

sudo nginx -t
sudo systemctl reload nginx

Verify from your local machine (not the server):

curl -s https://mcp.example.com/health

This should return {"status":"ok"}. If it does, Nginx is correctly proxying to your MCP server over TLS.

Firewall rules

Block direct access to port 3000 from outside. Only Nginx (on localhost) should reach the MCP server:

sudo apt install -y ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow ssh
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable

Verify port 3000 is not accessible from outside:

# From your local machine, this should time out or be refused:
curl -s --connect-timeout 5 http://YOUR_VPS_IP:3000/health

The connection should fail. Your MCP server is only reachable through Nginx on port 443.

How do you connect MCP clients to a remote server?

With the server running behind Nginx with TLS, configure your MCP clients to connect.

Claude Code

Add the remote server using the CLI:

claude mcp add --transport http \
  --header "Authorization: Bearer YOUR_TOKEN_HERE" \
  notes-server https://mcp.example.com/mcp

Replace YOUR_TOKEN_HERE with the token from /etc/mcp-notes-server/env.

Verify the connection:

claude mcp list

Inside Claude Code, type /mcp to check the server status. The notes-server should show as connected with the search_notes and create_note tools available.

[-> run-claude-code-vps]

Claude Desktop

Claude Desktop connects to remote MCP servers through the Settings UI. Go to Settings > Connectors and add a new remote server with the URL https://mcp.example.com/mcp.

If you need to pass the bearer token, you can also configure it using a .mcp.json file locally or the claude mcp add CLI command shown above and import the server into Claude Desktop:

claude mcp add-from-claude-desktop

Cursor

Open Cursor settings and navigate to the MCP configuration. Add a new server in the mcp.json file (or through the Cursor settings UI):

{
  "mcpServers": {
    "notes-server": {
      "url": "https://mcp.example.com/mcp",
      "headers": {
        "Authorization": "Bearer YOUR_TOKEN_HERE"
      }
    }
  }
}

Cursor tries Streamable HTTP first when connecting to a URL, so no extra transport config is needed.

Verify the end-to-end connection

From any connected client, ask the AI to:

  1. Create a note: "Create a note titled 'Server Test' with content 'Connected successfully'"
  2. Search for it: "Search my notes for 'Server Test'"

If both operations return results, the full chain works: client to Nginx to MCP server to SQLite and back.

MCP primitives reference

Primitive Purpose Example
Tools Functions the AI can call with structured inputs search_notes, create_note
Resources Read-only data the AI can reference notes://all note listing
Prompts Pre-written templates for common tasks Not used in this tutorial

Tools are the most commonly implemented primitive. Resources are useful for exposing reference data without computation. Prompts let you create reusable templates that guide the AI through specific workflows.

[-> ai-agent-protocols-explained]

Something went wrong?

Server won't start: Check the journal for errors:

journalctl -u mcp-notes-server -n 50 --no-pager

Common causes: missing MCP_AUTH_TOKEN env var, wrong file permissions on notes.db, Node.js version too old.

Nginx returns 502 Bad Gateway: The MCP server is not running or not listening on port 3000. Verify:

sudo systemctl status mcp-notes-server
curl -s http://127.0.0.1:3000/health

Client gets 401 Unauthorized: The token in the client config does not match the token on the server. Double-check:

sudo cat /etc/mcp-notes-server/env

Streaming responses time out: Increase proxy_read_timeout in the Nginx config. The default 300s handles most cases, but long-running tools may need more.

Certificate errors: Verify the certificate is valid and covers your domain:

sudo certbot certificates

If the cert expired, renew it:

sudo certbot renew

Database locked errors: SQLite WAL mode handles concurrent reads well, but concurrent writes can conflict. For high-traffic servers, consider switching to PostgreSQL.

Permission denied on notes.db: The mcpserver user needs write access to the working directory for SQLite. Check:

ls -la /opt/mcp-notes-server/notes.db

The file should be owned by mcpserver:mcpserver. If it was created by root during testing, fix it:

sudo chown mcpserver:mcpserver /opt/mcp-notes-server/notes.db
sudo chmod 640 /opt/mcp-notes-server/notes.db

Client shows "connection refused" or "timeout": Check that your DNS A record points to the VPS IP and that ports 80 and 443 are open in the firewall:

sudo ufw status
dig +short mcp.example.com

The dig output should show your VPS IP address. If it does not, update your DNS records and wait for propagation.

Next steps

Your MCP server is live and locked down. Where to go from here:

  • Add more tools that connect to your databases, APIs, or file systems
  • Implement OAuth 2.1 instead of bearer tokens for multi-user access
  • Add rate limiting in Express or Nginx to protect against abuse
  • Set up monitoring with journalctl -u mcp-notes-server -f and alerting
  • Deploy multiple MCP servers behind the same Nginx instance on different paths

The official MCP SDK documentation covers advanced patterns including stateful sessions, progress reporting, and structured output schemas.


Copyright 2026 Virtua.Cloud. All rights reserved. This content is original work by the Virtua.Cloud team. Reproduction, republication, or redistribution without written permission is prohibited.

Ready to try it yourself?

Deploy your own server in seconds. Linux, Windows, or FreeBSD.

See VPS Plans