在VPS上构建并自托管MCP服务器

2 分钟阅读·Matthieu|

从零开始构建TypeScript MCP服务器,使用systemd部署到VPS,通过Nginx和TLS对外提供服务。将Claude Desktop、Claude Code和Cursor连接到你的自托管服务器。

所有MCP服务器教程都止步于"用stdio在本地运行"。本文覆盖完整流程:构建TypeScript MCP服务器,在VPS上通过Nginx和TLS部署,用认证和防火墙规则加固,然后通过互联网连接MCP客户端。

完成后,你将拥有一个Claude Desktop、Claude Code、Cursor或任何MCP兼容客户端都能远程访问的生产MCP服务器。

你将构建什么?

MCP客户端通过HTTPS发送JSON-RPC请求。Nginx终结TLS并将请求代理到本地端口上运行的MCP服务器进程。MCP服务器验证bearer token,处理请求并返回结果。

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

示例是一个笔记服务器,暴露两个工具(搜索笔记、创建笔记)和一个资源(列出所有笔记)。足够简单便于理解,也足够实用便于扩展。

远程MCP服务器应该使用什么传输协议?

Streamable HTTP是当前MCP规范中远程部署的标准协议。它取代了已废弃的SSE传输,通过普通HTTP POST请求工作。Stdio仍然是本地服务器的传输协议,客户端以子进程方式启动服务器。

传输协议 使用场景 支持远程 客户端支持
stdio 本地工具、CLI集成 所有客户端
SSE(已废弃) 旧版远程服务器 多数客户端(逐步淘汰)
Streamable HTTP 远程服务器、生产环境 Claude Desktop、Claude Code、Cursor

Streamable HTTP将每条客户端消息作为HTTP POST发送到单一端点(如/mcp)。服务器对简单回复返回application/json,需要流式传输多条消息时返回text/event-stream。这意味着它兼容标准HTTP基础设施,包括反向代理和负载均衡器。

前置条件

  • 运行Debian 12或Ubuntu 24.04的VPS(本指南使用Debian 12)
  • 已将DNS指向VPS的域名(如mcp.example.com
  • VPS上已安装Node.js 20+
  • 基本了解TypeScript和Linux

如何搭建TypeScript MCP服务器项目?

在本地机器或直接在VPS上操作。初始化项目,安装MCP SDK,配置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

创建TypeScript配置:

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

保存为项目根目录下的tsconfig.json

更新package.json,添加build和start脚本并设置模块类型:

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

创建源代码目录:

mkdir src

如何实现带输入验证的MCP工具?

MCP服务器向AI客户端暴露工具(tools)、资源(resources)和提示词(prompts)。工具是AI可以调用的函数。资源是AI可以引用的只读数据。我们将同时实现这两者。

创建src/index.ts,包含数据库设置和一个工厂函数,为每个请求构建新的MCP服务器实例。无状态Streamable HTTP要求每个请求使用新的McpServer实例,因为SDK不允许将同一个服务器重新连接到不同的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",
  });

在工厂函数内注册工具。每个工具使用Zod做输入验证。SDK会在handler运行前自动验证输入。

  // --- 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}`,
          },
        ],
      };
    }
  );

每个输入在到达handler之前都会被Zod验证。如果客户端发送{ query: "" },SDK会自动返回验证错误。不需要手动解析。

如何向MCP服务器添加资源?

资源(resources)给AI客户端提供只读数据访问,通过URI引用。添加一个列出所有笔记的资源,仍然在createServer工厂函数内:

  // --- 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客户端可以发现并读取此资源,无需调用工具。Claude Desktop在附件菜单中显示资源。Claude Code通过@提及来暴露资源。

如何用token认证保护MCP服务器?

生产环境的MCP服务器需要认证。没有认证,任何发现你端点的人都能调用你的工具。在请求到达MCP transport之前,使用Express中间件检查bearer token。

生成一个强token:

openssl rand -base64 32

保存输出。服务器配置和客户端配置都需要它。

src/index.ts中添加认证中间件和Express路由:

// --- 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);

中间件在每个/mcp请求之前运行。它检查Authorization头中的Bearer token,并以JSON-RPC错误响应拒绝未授权的请求。

如何设置Streamable HTTP传输?

添加transport设置和路由处理器。每个请求从工厂创建新的McpServer和新的transport实例:

// --- 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`);
});

设置sessionIdGenerator: undefined创建无状态服务器。每个请求独立运行,拥有自己的McpServer和transport对。这种方式部署更简单,在反向代理后面无需sticky session即可正常工作。数据库连接(db)在模块作用域中,跨请求共享,因此数据在调用之间持久化。

服务器绑定到127.0.0.1,不是0.0.0.0。它只接受来自本地的连接。Nginx将是从外部访问的唯一入口。

构建并本地测试:

npm run build

确认构建成功,没有错误。

如何在本地测试MCP服务器?

MCP Inspector是官方测试工具。它连接到你的服务器,让你交互式地调用工具。

在一个终端启动服务器:

MCP_AUTH_TOKEN=test-token-local npm start

在另一个终端用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
  }'

你应该看到一个SSE响应(event: message后跟data:行),包含服务器的capabilities,其中有toolsresources对象。这确认服务器接受连接并响应MCP初始化握手。

测试create_note工具:

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
  }'

响应应包含"Note created with id: ..."。现在测试搜索工具:

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
  }'

你应该在结果中看到刚创建的笔记。验证不带Authorization头的请求返回401错误:

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}'

这应该输出401。如果是,你的认证中间件正常工作。

你也可以使用MCP Inspector进行带Web UI的交互式测试:

npx @modelcontextprotocol/inspector

Inspector会打开浏览器界面,你可以连接到服务器,浏览工具和资源,测试调用。

如何将MCP服务器部署到VPS?

使用rsyncscp将项目传输到VPS,或推送到Git仓库后在服务器上克隆。以下步骤假设你已登录VPS。

创建专用用户

永远不要以root运行应用进程。创建一个服务用户:

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

安装Node.js

如果尚未安装Node.js 20+:

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

第一条命令将安装脚本下载到文件,便于运行前检查。确认输出显示Node.js为v20.x.x或更高,npm为10.x.x。MCP SDK要求Node.js 18+,但生产环境推荐20 LTS。

设置项目目录

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

验证所有权:

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

所有文件应属于mcpserver:mcpserver

创建环境文件

将认证token存储在受限的环境文件中:

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

读取生成的token并安全保存。客户端配置需要用到:

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

创建systemd服务

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从受限文件加载MCP_AUTH_TOKEN。加固部分限制了进程的权限。NoNewPrivileges防止权限提升。ProtectSystem=strict使文件系统只读,ReadWritePaths中列出的路径除外。ProtectHome阻止访问用户主目录。

启用并启动服务:

sudo systemctl enable --now mcp-notes-server

enable使服务开机自启。--now立即启动。

检查状态:

sudo systemctl status mcp-notes-server

你应该看到active (running)。如果服务失败,检查日志:

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

验证服务器在本地响应:

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

这应该返回{"status":"ok"}

如何通过Nginx和TLS运行MCP服务器?

Nginx终结TLS并将请求代理到MCP服务器。MCP服务器永远不直接处理TLS。这是生产环境Node.js部署的标准模式。

安装Nginx和Certbot

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

配置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

mcp.example.com替换为你的实际域名。

启用站点并获取TLS证书:

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会修改配置,添加TLS设置和HTTP到HTTPS的重定向。现在更新配置,为MCP端点添加反向代理:

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

MCP兼容性的关键指令:

  • proxy_buffering off防止Nginx缓冲SSE响应。没有它,流式响应会挂起直到连接关闭。
  • proxy_cache off确保不缓存动态响应。
  • chunked_transfer_encoding off避免SSE事件流的分块传输问题。
  • Connection ""保持连接活跃,不触发WebSocket升级行为。
  • proxy_read_timeout 300s允许工具调用最长执行5分钟。默认的60秒对于复杂操作来说太短。

测试并重新加载:

sudo nginx -t
sudo systemctl reload nginx

从本地机器(不是服务器)验证:

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

这应该返回{"status":"ok"}。如果返回了,说明Nginx正确地通过TLS将请求代理到你的MCP服务器。

防火墙规则

阻止从外部直接访问端口3000。只有Nginx(在localhost上)可以访问MCP服务器:

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

验证端口3000从外部不可访问:

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

连接应该失败。你的MCP服务器只能通过Nginx的443端口访问。

如何连接MCP客户端到远程服务器?

服务器在Nginx后面运行并配置了TLS,现在配置MCP客户端进行连接。

Claude Code

使用CLI添加远程服务器:

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

YOUR_TOKEN_HERE替换为/etc/mcp-notes-server/env中的token。

验证连接:

claude mcp list

在Claude Code中,输入/mcp检查服务器状态。notes-server应显示为已连接,search_notescreate_note工具可用。

Claude Desktop

Claude Desktop通过设置UI连接远程MCP服务器。进入Settings > Connectors,添加新的远程服务器,URL为https://mcp.example.com/mcp

如果需要传递bearer token,也可以通过本地.mcp.json文件或上面的claude mcp addCLI命令配置,然后将服务器导入Claude Desktop:

claude mcp add-from-claude-desktop

Cursor

打开Cursor设置,导航到MCP配置。在mcp.json文件中添加新服务器(或通过Cursor设置UI):

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

Cursor连接URL时会优先尝试Streamable HTTP,无需额外传输配置。

验证端到端连接

从任何已连接的客户端,要求AI:

  1. 创建笔记:"Create a note titled 'Server Test' with content 'Connected successfully'"
  2. 搜索笔记:"Search my notes for 'Server Test'"

如果两个操作都返回结果,整个链路正常工作:客户端到Nginx到MCP服务器到SQLite再返回。

MCP原语参考

原语 用途 示例
Tools(工具) AI可以用结构化输入调用的函数 search_notescreate_note
Resources(资源) AI可以引用的只读数据 notes://all笔记列表
Prompts(提示词) 常见任务的预写模板 本教程未使用

工具是最常实现的原语。资源适合暴露无需计算的参考数据。提示词让你创建可复用的模板来引导AI完成特定工作流。

出了问题?

服务器无法启动: 检查日志:

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

常见原因:缺少MCP_AUTH_TOKEN环境变量,notes.db文件权限错误,Node.js版本太旧。

Nginx返回502 Bad Gateway: MCP服务器未运行或未监听端口3000。验证:

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

客户端收到401 Unauthorized: 客户端配置中的token与服务器上的token不匹配。检查:

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

流式响应超时: 增加Nginx配置中的proxy_read_timeout。默认的300秒可以处理大多数情况,但长时间运行的工具可能需要更多时间。

证书错误: 验证证书有效且覆盖你的域名:

sudo certbot certificates

如果证书过期,续期:

sudo certbot renew

数据库锁定错误: SQLite WAL模式能很好地处理并发读取,但并发写入可能冲突。对于高流量服务器,考虑切换到PostgreSQL。

notes.db权限被拒绝: mcpserver用户需要对工作目录的写入权限。检查:

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

文件应属于mcpserver:mcpserver。如果在测试时由root创建,修复:

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

客户端显示"connection refused"或"timeout": 检查DNS A记录指向VPS IP,且防火墙开放了80和443端口:

sudo ufw status
dig +short mcp.example.com

dig输出应显示你的VPS IP地址。如果没有,更新DNS记录并等待传播。

下一步

你的MCP服务器已上线并加固。接下来可以:

  • 添加更多工具连接你的数据库、API或文件系统
  • 用OAuth 2.1替代bearer token实现多用户访问
  • 在Express或Nginx中添加速率限制防止滥用
  • journalctl -u mcp-notes-server -f设置监控和告警
  • 在同一个Nginx实例后面的不同路径上部署多个MCP服务器

MCP SDK官方文档覆盖了高级模式,包括有状态会话、进度报告和结构化输出schema。


Copyright 2026 Virtua.Cloud. All rights reserved.

准备好亲自尝试了吗?

几秒内部署您自己的服务器。支持 Linux、Windows 或 FreeBSD。

查看 VPS 方案