SSD Nodes Learn
指南 Matt Connor作者: Matt Connor · 更新于 2026-07-19

在 VPS 上为 AI 编程代理运行 MCP 服务器

在自己的 VPS 上运行 MCP(模型上下文协议)服务器,让 AI 代理用上真正的工具:stdio 与远程 HTTP 传输、systemd、TLS、认证,以及每一种故障模式。

您将搭建的内容

在一台 VPS 上搭建两套可用的 MCP 环境。首先是一个 stdio 服务器,也就是一个文件系统或数据库工具,Claude Code 会把它作为子进程启动,并通过管道与它通信。然后是一个 远程 HTTP 服务器,它作为一个长期运行的网络服务,运行在 systemd 之下,前面架着一个带 TLS 的 nginx 反向代理,任何您指向它的 MCP 客户端都能访问。这两种方式的安装都很简单。本指南的大部分内容,讲的是真正会让您栽跟头的两件事:保持 JSON-RPC 数据流干净,以及绝不把一个未经认证的工具端点放到公网上。

MCP 到底是什么

模型上下文协议(Model Context Protocol,MCP)是一种标准方式,让一个 AI 客户端(Claude Code、Claude Desktop、运行在 VPS 上的 Gemini CLI,或者您自己写的脚本)去调用外部工具、读取外部资源。模型本身不运行任何东西。它向客户端提出请求,客户端用 JSON-RPC 2.0 与一个 MCP 服务器通信,服务器运行工具,再把结果交回去。协议只有一套,所以您写一次的服务器,能配合每一个说 MCP 的客户端工作。

MCP 有两种传输方式,本指南接下来的全部内容都是沿着它们分开讲的:

  • stdio。 客户端把服务器作为子进程启动,通过它的标准输入和标准输出交换以换行符分隔的 JSON-RPC 消息。没有网络,没有端口,没有认证,信任边界就是进程本身。几乎每一个本地工具都以这种方式发布。
  • Streamable HTTP(以及它更早的表亲 HTTP+SSE)。服务器是一个长期运行的 Web 服务。客户端通过 HTTP 连接,服务器可以用服务器发送事件(Server-Sent Events,SSE)把响应流式返回。当您要把一个服务器共享给多个客户端,或者运行一个必须永久驻留在机器上的工具时,就用这种方式。

当工具只属于一台机器、一个用户时,选 stdio。当它是一个共享服务时,选 HTTP。

前提条件与实话实说的坑

假设您有一台全新的 Ubuntu 24.04 KVM VPS,拥有 root 或 sudo 权限。除此之外:

  • 服务器所用语言的运行时。 大多数参考实现的服务器用 Node 或 Python 写成。Ubuntu 24.04 自带 Node 18,而当前好几个 MCP 包需要 Node 20 或更新,所以请从 NodeSource 或 nvm 安装一个当前的 LTS 版本,不要指望 apt。Python 3.12 系统里已经有了。
  • 一个域名和 DNS A 记录,但这只针对远程 HTTP 服务器,因为 TLS 需要一个能解析到这台 VPS 的名字。stdio 示例完全不需要 DNS。
  • 512 MB 内存绰绰有余。 MCP 服务器是很轻的 JSON-RPC 进程;内存开销取决于您的工具碰到什么(一个数据库驱动、一个文件缓存),而不是协议本身。
  • 规范还很年轻,一直在变。 2025-03-26 版本用 Streamable HTTP 取代了 HTTP+SSE,并把 SSE 标记为已弃用。SSE 仍然能用,不少服务器也仍然只说 SSE,所以请把任何传输方式的固定选择,当作一件需要对照服务器发布说明重新核实的事,而不是当作金科玉律。

第 1 步:把一个 stdio 服务器接入 Claude Code

从文件系统服务器开始,它是官方的、在积极维护的,除了 Node 什么都不需要。下面这一条命令就能把它注册到 Claude Code,并把它的作用范围限定在当前项目,这样它会落到一个可以提交进版本库的文件里:

cd /home/matt/projects/api
claude mcp add --scope project --transport stdio filesystem \
  -- npx -y @modelcontextprotocol/server-filesystem /home/matt/projects/api

-- 分隔符很重要:它后面的一切都是 Claude Code 将要运行的命令,而不是给 Claude Code 的标志。这会在项目根目录写出一个 .mcp.json:

{
  "mcpServers": {
    "filesystem": {
      "command": "npx",
      "args": [
        "-y",
        "@modelcontextprotocol/server-filesystem",
        "/home/matt/projects/api"
      ]
    }
  }
}

此时还没有任何东西在运行。当您下次在这个目录里启动 Claude Code 时,代理会读取 .mcp.json,把 npx -y @modelcontextprotocol/server-filesystem ... 作为子进程启动,并通过那个进程的 stdin/stdout 完成 MCP 握手。确认它生效了:

claude mcp list

一个健康的服务器会打印出它的命令和一个绿色对勾:filesystem: npx -y @modelcontextprotocol/server-filesystem ... - ✓ Connected。在会话内部,/mcp 斜杠命令会列出服务器暴露的工具(read_filewrite_filelist_directory),代理现在就能在您允许的路径上调用它们。数据库工具是同样的形状,换一个包、把连接字符串作为最后一个参数传进去就行,但请去服务器自己的仓库确认当前的包名,因为那个参考用的 Postgres 服务器已经不止一次易手了。

在这台机器上运行代理,意义正在于此:Claude Code 会话运行在 VPS 上的 tmux 里,它的 stdio 服务器就紧挨着它运行,能直接访问项目文件和本地服务,没有网络往返。

第 2 步:搭建一个远程 HTTP 服务器

一个 stdio 服务器会随着它的父进程一起消亡。当您想要一个为每个客户端保持运行的工具时(一个共享的运维工具、一个数据库网关、某个您的笔记本和 CI 都会调用的东西),您就需要 HTTP 传输方式和一个真正的服务。下面是一个用官方 SDK 写的最小 Python 服务器,暴露一个工具:

# /opt/mcp-ops/server.py
from mcp.server.fastmcp import FastMCP
import subprocess

mcp = FastMCP("ops-tools", host="127.0.0.1", port=8000)

@mcp.tool()
def disk_free() -> str:
    """Return `df -h` for the server."""
    out = subprocess.run(["df", "-h"], capture_output=True, text=True)
    return out.stdout

if __name__ == "__main__":
    # Serves Streamable HTTP at /mcp on 127.0.0.1:8000
    mcp.run(transport="streamable-http")

注意 host="127.0.0.1"。服务器只绑定到 localhost,机器外面的任何东西都无法直接触达它,而这正是在认证还不存在之前您想要的。把它装在自己独立的虚拟环境里,好让 systemd 有一个稳定的解释器路径:

sudo useradd --system --home /opt/mcp-ops --shell /usr/sbin/nologin mcp
sudo install -d -o mcp -g mcp /opt/mcp-ops
sudo -H -u mcp python3 -m venv /opt/mcp-ops/.venv
sudo -H -u mcp /opt/mcp-ops/.venv/bin/pip install "mcp[cli]"

第 3 步:用 systemd 让它保持运行

一个在代理伸手去用时却处于宕机状态的工具,比没有工具还糟。写入 /etc/systemd/system/mcp-ops.service:

[Unit]
Description=MCP ops-tools server
After=network.target

[Service]
Type=simple
User=mcp
WorkingDirectory=/opt/mcp-ops
ExecStart=/opt/mcp-ops/.venv/bin/python /opt/mcp-ops/server.py
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true

[Install]
WantedBy=multi-user.target

ExecStart 里指向虚拟环境 Python 的绝对路径不是可选项:把它指向 /usr/bin/python3,进程就会带着 ModuleNotFoundError: No module named 'mcp' 启动失败,因为系统解释器从没见过您那次 pip install。启用并检查:

sudo systemctl daemon-reload
sudo systemctl enable --now mcp-ops
sudo systemctl status mcp-ops
curl -si -H 'Accept: application/json, text/event-stream' \
  -H 'Content-Type: application/json' \
  -X POST http://127.0.0.1:8000/mcp

status 应该显示 active (running)。那条 curl 会返回 HTTP/1.1 400 Bad Request,响应体里带一个 JSON-RPC 错误,因为请求没有携带会话、也没有有效的 JSON 载荷,而这恰恰是您想要的:它证明端口有应答、并且说这个协议。如果返回 Connection refused 或空响应,说明进程没有绑定在您以为的位置;去看 journalctl -u mcp-ops -n 50

第 4 步:在前面架上 TLS 和反向代理

服务器监听在 localhost 上。要从任何地方访问它,您就在 nginx 上终止 TLS 并向内代理。安装 nginx,用 nginx 上的 Certbot 和 Let's Encrypt 拿到一张证书,然后写这个 location 块。关键的部分是关闭缓冲,因为 nginx 默认的行为会一直攥着响应直到它完整,这会让一条 SSE 流永远卡住:

server {
    listen 443 ssl;
    server_name mcp.example.com;

    # ssl_certificate lines managed by Certbot

    location /mcp {
        proxy_pass http://127.0.0.1:8000;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;

        # The four lines that make SSE work through nginx:
        proxy_buffering off;
        proxy_cache off;
        proxy_read_timeout 3600s;
        chunked_transfer_encoding off;
    }
}

sudo nginx -t && sudo systemctl reload nginx 重新加载。如果您已经在跑一群容器,同样的活儿可以交给一个 带自动 TLS 的 Traefik 反向代理 来干,它会签发证书并按主机名路由,您只需给 MCP 容器加上标签。无论哪种方式,反向代理现在都是公网端口上唯一的东西,而它指向的是一个您还没有加固的服务。在您把这个 URL 注册到任何地方之前,先把这一点修好。

第 5 步:主导这个话题的安全规则

绝不暴露一个未经认证的 MCP 端点。 一个 MCP 服务器不是只读 API。它授予的是工具访问权,访问您的文件、您的数据库,有时是一个 shell。公网上一个开放的 /mcp,就是一个和您的 AI 代理拥有同等触达能力的陌生人:他们先列出您的工具,然后调用它们。请把它完全当作一个未经认证的管理套接字来对待,因为它就是那个东西。

三道防线,按优先顺序排列:

  1. 不要发布它。 把服务器留在 127.0.0.1 上,从您的笔记本用 SSH 隧道访问它:ssh -L 8000:127.0.0.1:8000 matt@vps,然后把客户端指向 http://127.0.0.1:8000/mcp。没有任何东西被暴露过。
  2. 把它放到一个私有网络上。 绑定到一个 自建 WireGuard VPN 的隧道地址,只让 VPN 对端能访问它。公网看到的是一个关闭的端口。
  3. 如果它必须公开,就要求一个令牌。 正规的答案是 HTTP 传输方式原生支持的 MCP OAuth 流程。务实的最低限度,是在代理处校验一个共享的 bearer 令牌,便宜,而且它能彻底挡住顺手一试的攻击:
location /mcp {
    if ($http_authorization != "Bearer REPLACE_WITH_LONG_RANDOM") {
        return 401;
    }
    proxy_pass http://127.0.0.1:8000;
    # ...buffering-off block from above...
}

openssl rand -hex 32 生成令牌,而且在没有以上任何一道防线的情况下,绝不要把服务器本身绑定到 0.0.0.0。客户端随后会把令牌作为一个头发送。在 Claude Code 里:

claude mcp add --scope project --transport http ops-tools https://mcp.example.com/mcp \
  --header 'Authorization: Bearer ${MCP_TOKEN}'

在您的 shell 里设置 MCP_TOKEN,这样密钥就绝不会以明文落进 .mcp.json,Claude Code 会在读取时从环境里展开 ${MCP_TOKEN}

第 6 步:用 MCP Inspector 调试

当一个服务器行为异常时,不要在代理内部瞎猜,用 Inspector 直接驱动它,这是官方的基于 Web 的测试客户端。对于一个 stdio 服务器,把代理运行的同一条命令交给它:

npx @modelcontextprotocol/inspector \
  npx -y @modelcontextprotocol/server-filesystem /tmp

它会在 http://localhost:6274 上启动一个 UI(近期的版本会打印一个带 MCP_PROXY_AUTH_TOKEN 查询串的 URL,请用那条完整链接,否则 UI 会拒绝您),并在 6277 上启动一个代理。点 Connect,再点 List Tools,再用真实参数点 Call Tool。如果它在 Inspector 里能用、却在代理里失败,那么问题出在您的客户端配置里,而不是服务器。对于远程 HTTP 服务器,选择 Streamable HTTP 传输方式,输入 https://mcp.example.com/mcp,加上 Authorization 头,然后连接,这是在任何代理介入之前,证明认证和代理都正确的最快办法。

让服务器保持更新

MCP 变化很快,所以要按计划打补丁。用 npx -y 启动的 Node 服务器,每次启动都会拉取最新版本,这很方便,但不可复现;一旦某个服务器变得重要,就把您测试过的确切版本固定下来,用 npm view @modelcontextprotocol/server-filesystem version 读出它,把它追加到 .mcp.json 里的包名后面(@modelcontextprotocol/server-filesystem@<version>),然后有意识地手动升级。systemd 之下的 Python 服务器,用 sudo -H -u mcp /opt/mcp-ops/.venv/bin/pip install -U "mcp[cli]" 更新,随后 sudo systemctl restart mcp-ops。升级时留意您的 SDK 所对应的规范版本,一次跨越 SSE 到 Streamable HTTP 边界的跳变,可能会改变您的客户端必须请求的传输方式。

各种故障模式,以及您会看到的字符串

代理显示服务器启动失败。 claude mcp list 打印 ✗ Failed to connect,TUI 报告 MCP server 'filesystem' failed to start。运行 claude --debug,您通常会看到 Error: spawn npx ENOENT,也就是命令不在代理的 PATH 上。运行时缺失或者不在代理查找的位置:Node 没装、npx 不在,或者一个虚拟环境的 Python 被用裸名字引用了。把命令改成绝对路径或者安装运行时,然后重新连接。

一个 stdio 服务器连上了,却立刻掉线。 客户端记录一个 JSON 解析错误,类似 Unexpected token 'S', "Server sta"... is not valid JSONFailed to parse message。原因永远是同一个:服务器往 stdout 写了一行日志。在 stdio 上,stdout 就是 JSON-RPC 通道,所以任何乱入的文本都会破坏数据流,握手随之失败。在 Node 里,console.log 走 stdout,请改用 console.error。在 Python 里,一个裸的 print() 走 stdout,请用配置成写往 sys.stderrlogging 来记日志,或者传 file=sys.stderr。规则是绝对的:在 stdio 上,stdout 上只有 JSON-RPC,一切给人看的东西都走 stderr。

一个远程服务器超时,或者在握手中途关闭。 客户端以 MCP error -32000: Connection closed 失败,或者 Inspector 卡在 Connect 上、始终列不出工具。在 nginx 后面,这是缓冲的问题:代理攥着 SSE 流而不把它冲刷出去,于是客户端在等一个永远不会到来的响应。给这个 location 加上 proxy_buffering off;(以及第 4 步里那个块的其余部分)。用对公开 URL 的 curl -N 来确认,您应该看到事件数据是逐步到达的,而不是全部在末尾一次性到达。

认证被拒绝。 客户端报告 Error POSTing to endpoint (HTTP 401),或者直白的 401 Unauthorized。要么是头缺失,要么是令牌不对,要么是客户端读取配置时那个 shell 变量是空的,这是一个常见的陷阱,因为如果变量没设,${MCP_TOKEN} 会展开成空,于是 nginx 看到的是 Bearer 后面什么都没有。回显那个变量,重新添加头,核实确切的字节与 nginx if 里的令牌相符。

服务在 systemd 之下起不来。 journalctl -u mcp-ops 显示 ModuleNotFoundError: No module named 'mcp',说明 ExecStart 指向的是系统 Python 而不是虚拟环境的解释器。或者显示 Address already in use,说明另一个进程占着 8000;用 sudo ss -ltnp | grep 8000 找出它。

FAQ

MCP 服务器到底是什么?

它是一个程序,通过模型上下文协议、用 JSON-RPC 2.0,向一个 AI 客户端暴露工具和资源。AI 模型从不自己运行工具,它向它的客户端提出请求,客户端调用 MCP 服务器,服务器执行并返回结果。因为协议是标准的,一个服务器能配合任何合规的客户端工作,无论那是 Claude Code、Claude Desktop 还是 Gemini CLI。

stdio 和 HTTP 传输方式有什么区别?

stdio 服务器由客户端作为子进程启动,通过 stdin/stdout 通信,所以它随一个客户端在一台机器上共存亡,不需要网络或认证。HTTP 服务器是一个长期运行的网络服务,许多客户端能同时访问它,这就是它为什么需要 TLS 和认证。本地的、单用户的工具用 stdio;任何共享或持久的东西用 HTTP(当前的服务器用 Streamable HTTP)。

我该如何加固一个远程 MCP 服务器?

假设它授予对您文件、数据库或 shell 的工具访问权,永远不要在未经认证的情况下暴露它。最好是让它绑定在 localhost,通过 SSH 隧道或私有 VPN 访问;如果它必须公开,就把它放在一个强制校验 bearer 令牌或 MCP OAuth 流程的反向代理后面。用 openssl rand -hex 32 生成令牌,在没有以上任何一道防线的情况下,绝不要把服务器绑定到 0.0.0.0

我该如何调试一个起不来的服务器?

先查 claude mcp list,✗ Failed to connect 加上 spawn ... ENOENT 意味着命令或运行时缺失,那就修正路径或安装它。如果它连上了然后带着一个 JSON 解析错误掉线,说明服务器在往 stdout 记日志、把 JSON-RPC 数据流弄坏了;把所有日志挪到 stderr。其他任何情况,就在 MCP Inspector 下运行那条确切的命令,它会隔离地驱动服务器,让您能分清是服务器的 bug 还是客户端配置的问题。