在VPS上构建并自托管MCP服务器
从零开始构建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,其中有tools和resources对象。这确认服务器接受连接并响应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?
使用rsync、scp将项目传输到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_notes和create_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:
- 创建笔记:"Create a note titled 'Server Test' with content 'Connected successfully'"
- 搜索笔记:"Search my notes for 'Server Test'"
如果两个操作都返回结果,整个链路正常工作:客户端到Nginx到MCP服务器到SQLite再返回。
MCP原语参考
| 原语 | 用途 | 示例 |
|---|---|---|
| Tools(工具) | AI可以用结构化输入调用的函数 | search_notes、create_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.