很多人第一次折腾 MCP,都是先在本机跑 stdio:Claude Desktop、Claude Code、Cursor 拉起一个子进程,工具能用就算成功。
但只要你开始遇到下面几种情况,本地 stdio 就不够了:
- 你想在笔记本、台式机、远程开发机上共用一套工具
- 你要把工具给团队成员或多个客户端一起用
- 你需要一个持续在线、可审计、可升级的远程入口
这时候才轮到 VPS 上的远程 MCP Server 出场。
先把结论放前面:2026 年新部署远程 MCP,主线应该是 Streamable HTTP,而不是老的 HTTP+SSE。 stdio 还是本地集成的默认方案;只有当你真的需要远程访问、HTTPS、鉴权、反向代理和多客户端复用时,才值得把它放到 VPS 上。
口径说明:本文以 MCP
2025-11-25已发布规范为准,同时提醒你初始化阶段会做协议版本协商。也就是说,客户端、SDK、Server 不一定都停在同一个版本号上,HTTP 场景要看协商结果和MCP-Protocol-Version,不要写死“所有客户端都只支持某一个版本”。
如果你的工具只在本机用,比如读本地代码仓库、调用本地数据库、跑只给自己看的脚本,stdio 依然是最省事的。
stdio 的边界很明确:
- 客户端自己拉起子进程
- 权限基本继承本地执行环境
- 不需要域名、反向代理、HTTPS、OAuth
- 也不适合直接给别的设备远程连
远程 MCP 更适合这几类场景:
- 你要把只读业务工具挂到公网 HTTPS 入口
- 你要让 Claude、Cursor、内部工具台共用一个服务
- 你希望把日志、配额、升级、下游 API 访问统一收口
如果你还在估 VPS 怎么选,可以先看 VPS 配置选择指南。
| 方式 | 适合什么场景 | 现在怎么用 |
|---|---|---|
stdio | 本地客户端拉起本地工具 | 继续用,本地默认 |
老 HTTP+SSE | 兼容旧客户端 | 只在必须兼容旧实现时保留 |
Streamable HTTP | 新的远程部署主线 | VPS、自托管、HTTPS、鉴权都走它 |
关键点有三个:
stdio不是远程部署协议。 它是“客户端启动本地子进程”的方式。- Streamable HTTP 用的是单一 MCP endpoint。 客户端通过 POST 发 JSON-RPC,请求可以返回 JSON,也可以返回 SSE 流;必要时客户端还能对同一 endpoint 发 GET 建立服务端消息流。
- 老 HTTP+SSE 已经被替代。 旧的“双 endpoint”模式只适合兼容历史客户端,不该再当成新项目默认方案。
所以如果你现在要在 VPS 上新建服务,最稳妥的思路是:
- 对新客户端:优先 Streamable HTTP
- 对旧客户端:明确做兼容决策,必要时额外保留旧 endpoint
- 不要做“自动降级到更松散、更不安全的老模式”
MCP Server 本身往往不是最吃资源的部分,真正吃资源的通常是:
- 下游数据库
- 浏览器自动化
- 向量检索
- 模型 API 调用
- 文件转换、OCR、爬虫
如果你这篇文章里的示例只是一个只读工具,1C1G 或 2C2G 可以作为“本文示例规模”的起点。但这不是 MCP 官方最低要求,也不是通用承诺。你最终要按自己的工具负载来估。
经验上更应该优先关注:
- 你的工具是不是会并发打下游 API
- 是否需要状态缓存或会话存储
- 日志量、备份量、失败重试量有多大
- 是否要和其他应用共用同一台 VPS
如果你准备用 Compose 承载多个服务,可以再看 Docker Compose 生产环境健康检查与资源限制指南。
本文用的是一个很克制的架构:
Claude / Cursor / 其他 MCP Client
↓ HTTPS
Caddy
↓ Docker 内网
FastMCP Streamable HTTP
↓ allowlist / 只读访问
固定的数据源或内部 API
这里要分清协议要求和运维选择:
- MCP 协议层面:授权对整个 MCP 生态来说是可选能力,不是每个实现都必须上 OAuth。
stdio本地场景通常继承本机执行环境;但本文这套面向公网、可被远程客户端访问的 HTTP 生产架构,必须做认证,而且一旦实现 MCP HTTP auth,就应该按 MCP authorization 规范来做资源元数据、Bearer header、token audience/resource 校验和 HTTPS。 - 本文运维选择:FastMCP、Docker Compose、Caddy、只暴露 80/443、独立 health endpoint、日志轮转、资源限制
也就是说,Caddy 和 Compose 不是 MCP 协议强制项,只是这套落地方案比较顺手、好维护。
示例里我故意不用任意 shell、任意路径、任意 URL 抓取,也不做“万能查询工具”。远程 MCP 最怕的不是功能少,而是入口太宽、参数太自由、最后什么都能被模型试出来。
下面直接给一个可以和后面的 Dockerfile、Compose、Caddy 对上号的完整 app.py。它把只读 tool、JWTVerifier、非敏感 /health 路由和 app = mcp.http_app(path="/mcp") 放在同一个文件里;Host / Origin 防护继续交给后面的官方环境变量配置:
import os
from typing import Literal
from fastmcp import FastMCP
from fastmcp.server.auth.providers.jwt import JWTVerifier
from starlette.requests import Request
from starlette.responses import PlainTextResponse
auth = JWTVerifier(
jwks_uri=os.environ["AUTH_JWKS_URI"],
issuer=os.environ["AUTH_ISSUER"],
audience=os.environ["AUTH_AUDIENCE"],
)
mcp = FastMCP("ops-readonly", auth=auth)
SERVICE_STATE = {
"api": {"status": "ok", "latency_ms": 32},
"worker": {"status": "degraded", "latency_ms": 85},
"billing": {"status": "ok", "latency_ms": 41},
}
@mcp.tool()
def get_service_status(service: Literal["api", "worker", "billing"]) -> dict:
"""Return a tiny, read-only status snapshot for one allowed service."""
item = SERVICE_STATE[service]
return {
"service": service,
"status": item["status"],
"latency_ms": item["latency_ms"],
}
@mcp.custom_route("/health", methods=["GET"])
async def health(_: Request) -> PlainTextResponse:
return PlainTextResponse("OK")
app = mcp.http_app(
path="/mcp",
)
这个例子安全边界比较清楚:
- 输入不是任意字符串,而是固定 allowlist
- 输出是小 JSON,不带 token、密码、原始日志
- 没有副作用,不写文件、不删数据、不发外部请求
/health单独存在,运维探活不需要真的调用高权限 tool- 认证从一开始就挂在
FastMCP(..., auth=auth)上,不会变成公开无认证/mcp app = mcp.http_app(path="/mcp")和后面的uvicorn app:app、Compose、Caddy 指向的是同一条入口- Host / Origin 防护不靠自定义参数“硬塞”进
http_app(...),而是由后面的FASTMCP_HTTP_HOST_ORIGIN_PROTECTION、FASTMCP_HTTP_ALLOWED_HOSTS、FASTMCP_HTTP_ALLOWED_ORIGINS统一提供
如果你要把工具接到数据库、工单系统或内部 API,建议继续保持这个思路:一把工具只做一件事,参数范围能收就收,返回字段能少就少。
生产里最常见的坑,是为了图省事直接 ports: ["8000:8000"] 对外,再在容器启动时临时 pip install 一把,最后连依赖版本和镜像内容都不可重复。这样做短期能跑,长期很容易留下洞。
更稳一点的做法是:依赖在镜像构建期安装,应用只在 Docker 内网提供 8000,公网入口只给 Caddy。
先把依赖固定在 requirements.txt:
fastmcp==3.2.4
uvicorn==0.35.0
再用一个明确的 Dockerfile:
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt \
&& useradd --create-home --uid 10001 appuser
COPY app.py /app/app.py
USER appuser
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
这个 app.py 的意义不是“所有 MCP 都必须照抄”,而是:当你把远程 HTTP MCP 放到公网生产环境时,至少要让它从第一天就是认证开启的,而不是一个公开无认证 endpoint。 如果你后面接的是 Keycloak、Scalekit、Azure 之类 provider,继续按它们各自的官方 provider 文档接,不要自己发明一套 OAuth 代码。
services:
mcp-app:
build:
context: .
dockerfile: Dockerfile
image: mcp-server:2026-07
expose:
- "8000"
environment:
FASTMCP_HTTP_HOST_ORIGIN_PROTECTION: "true"
FASTMCP_HTTP_ALLOWED_HOSTS: '["mcp.example.com"]'
FASTMCP_HTTP_ALLOWED_ORIGINS: '["https://app.example.com"]'
AUTH_JWKS_URI: "https://auth.example.com/.well-known/jwks.json"
AUTH_ISSUER: "https://auth.example.com"
AUTH_AUDIENCE: "https://mcp.example.com/mcp"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2).read()"]
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
restart: unless-stopped
read_only: true
tmpfs:
- /tmp
user: "10001:10001"
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
mem_limit: 512m
cpus: 1.0
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
- edge
caddy:
image: caddy:2.10
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
depends_on:
mcp-app:
condition: service_healthy
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
- edge
networks:
edge:
volumes:
caddy-data:
caddy-config:
这份 Compose 里有几个点是故意加上的:
- 固定版本:示例固定到了
python:3.12-slim、fastmcp==3.2.4、uvicorn==0.35.0、caddy:2.10,至少别用latest - 构建期安装依赖:容器启动时不再临时
pip install,镜像内容可复现 - 不直接开放应用端口:只有 Caddy 对公网暴露 80/443
- 独立探活:healthcheck 走
/health,不会真实执行业务 tool - 最小权限:
read_only、cap_drop: [ALL]、非 root、no-new-privileges - 资源限制:不至于让一个异常请求把整台机子拖死
- Host / Origin 防护:用官方
FASTMCP_HTTP_ALLOWED_HOSTS、FASTMCP_HTTP_ALLOWED_ORIGINS和FASTMCP_HTTP_HOST_ORIGIN_PROTECTION - 凭据注入:像
AUTH_JWKS_URI这类配置可走环境变量;如果你用的 provider 还要 client secret,再改成 secret file 注入,不要写进镜像和 Git - 日志轮转:至少把容器日志大小控住
真正长期跑时,建议把基础镜像 digest 也一起固定,升级前先在预发环境过一遍。
mcp.example.com {
encode zstd gzip
@health path /health
handle @health {
reverse_proxy mcp-app:8000
}
handle /mcp* {
reverse_proxy mcp-app:8000 {
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {host}
transport http {
read_timeout 30s
write_timeout 30s
}
}
}
request_body {
max_size 2MB
}
log {
output stdout
format json
}
}
这里的重点不是“Caddy 比谁更高级”,而是它正好适合做几件事:
- 自动 HTTPS
- 统一对外入口
- 让应用容器只留在内网
- 在代理层加超时、请求体限制、访问日志
这里我选的是Caddy 输出到 stdout,日志滚动交给 Docker json-file driver。边界要说清楚:Caddy 负责产生结构化访问日志;真正按 10m * 3 轮转的是容器运行时,不是 Caddy 自己的文件滚动器。
还有一个很容易漏掉的小坑:/mcp 和 /mcp/ 不要混用。 某些代理或客户端如果先被 301/308 重定向,再去补请求,Authorization 头有机会被丢掉。最稳的做法是:你在 app.py、Caddy、客户端配置里统一写同一个精确 URL,比如都写 https://mcp.example.com/mcp,不要靠跳转自动修正。
如果你对这一层还不熟,可以先看 Caddy 反向代理与自动 HTTPS 指南。
这一段特别容易写错。
放在 HTTP Authorization header:
Authorization: Bearer your-access-token
不要放 query string,不要放浏览器地址栏,也不要为了图省事做“固定 URL token”。规范明确说了,HTTP 请求里的 access token 不该进 URI。
至少要校验:
- 这个 token 是不是由对应授权服务器签发
- audience / resource 是不是指向当前 MCP resource
- 是否过期、是否 scope 不够
这一点特别重要,因为 token passthrough 是反模式。也就是说,你不能把客户端带来的 token 不做 audience 校验就直接转发给下游 API。
它主要防 DNS rebinding 一类问题。对 Streamable HTTP 来说,如果 Origin 存在且不合法,服务端应该直接回 403 Forbidden。这不是“额外加分项”,而是远程 MCP 的基本防线之一。
不是。
session 只是把一组交互关联起来,方便服务端维护状态、恢复 SSE 流、识别会话生命周期。它不能替代认证。真正的认证仍然要看每个 HTTP 请求里的 Bearer token。
规范里也写得很清楚:如果服务端结束了某个 session,客户端之后再带那个 session ID 请求,服务端应回 404 Not Found,客户端应该重新做初始化,而不是把旧 session 当成永久通行证。
再补一句和部署形态直接相关的:单机、单进程 VPS 可以继续用 stateful session。 但如果你后面要上多 worker、多个容器,或者做横向扩展,就该按 FastMCP 文档里 stateless_http=True 的设计走,并提前处理共享状态、会话恢复、以及外部存储的一致性问题。
FastMCP 文档已经给了 HTTP auth、StaticTokenVerifier、JWTVerifier 和不同 provider 的方向。这里要分两层看:
- MCP 规范层:HTTP auth 是可选能力,但一旦你实现了,就该按 MCP authorization 规范来
- 本文这套公网部署:默认就是要认证的,所以不能把
/mcp暴露成公开无认证入口
如果你要一个已经在官方文档里出现、语法稳定的例子,JWTVerifier 比“我替你手写一段 OAuth provider 接线代码”更稳。如果你没完全确认当前稳定分支的 provider API,不要在生产里照着博客硬抄一段“看起来能跑”的 OAuth 代码。
更稳的做法是把边界先定死:
- 远程服务只开放 HTTP transport
- 认证走标准 OAuth / JWT provider
- 资源元数据和授权服务器发现按规范暴露
- Caddy 负责 HTTPS,应用负责 Bearer 校验和 Origin 校验
- session 做会话,不做 auth
一套远程 MCP 上线后,不是“能连上”就完了,下面这些结果都要有:
- 带合法 Bearer token,完成 initialize / list-tools / tool call:成功
- 没带 token:401
- Origin 不在 allowlist:403
- session 过期或未知:404,然后客户端重新 initialize
- 服务端不支持某种方法:405
- Content-Type 不对或媒体类型不支持:415
如果你还要兼容旧客户端,建议把策略写明白:
- 新客户端:直接连 Streamable HTTP
/mcp - 旧客户端:你决定是否额外提供老 HTTP+SSE 兼容入口
- 不要因为兼容旧客户端,就默认关闭鉴权、关闭 Origin 校验,或者开放一个“临时无认证 endpoint”
能兼容是一回事,兼容时还保留同样的安全边界是另一回事。后者才决定你上线的是生产服务,还是只是把 demo 搬到了公网。
远程 MCP 最大的误区,是把风险全放在“公网暴露”上。其实就算你已经开了 HTTPS 和 OAuth,下面这些问题还是会中招:
- 提示注入:模型拿到恶意上下文后,诱导自己调用不该调的工具
- data exfiltration / 数据外泄:工具返回过多字段,把内部数据带出边界
- 最小权限失效:本来只想读状态,最后工具能读整库、跑 shell、扫目录
- 日志泄密:把 token、完整参数、下游原始响应写进日志
我更建议你用这张清单看自己的服务:
- 每个 tool 都是最小权限的吗?
- 有没有任意 shell、任意文件、任意 URL 这种高风险入口?
- 输出有没有做字段裁剪?
- 下游 API key 有没有通过环境变量或 secret file 注入?
- 出站访问是不是白名单或至少受控?
- 日志里有没有脱敏?
- 升级前有没有看 spec、SDK、FastMCP release notes?
- 备份的是源码、Compose、Caddy 配置和必要状态,而不是指望“容器本身就是备份”?
这里再补三条特别实用的运维建议。
第一,日志要能排障,但不要替攻击者补全上下文。比较稳的做法是记录请求 ID、tool 名称、耗时、HTTP 状态、下游调用是否超时;不要把 Bearer token、完整用户输入、原始上游响应整段落盘。
第二,升级前先看三样东西:MCP 规范变更、FastMCP/官方 SDK release notes、你正在使用的客户端版本。很多“昨天还能连,今天 401 / 415”的问题,不是代码突然坏了,而是 transport、header 或 auth 约定变了。
第三,把恢复步骤当成生产资产来维护。至少写清楚域名切换、Caddy 数据目录恢复、secret file 来源、下游 allowlist 重新下发、旧镜像回滚命令。远程 MCP 真出故障时,最值钱的不是你有多少配置片段,而是你能不能在 15 分钟内把 /mcp 恢复出来。
如果你的 MCP 还要接自托管 AI 网关,权限边界最好单独画清楚,别把一个能读数据的 MCP Server 和一个能切换上游模型的 AI 网关随便揉在一起。相关思路可以参考 AI API 网关自托管指南。
备份这块也别省,至少要把源码、配置、secret 管理方式和必要状态恢复流程演练一遍。真到事故时,有没有演练过比“你备份了几个 tar 包”更重要,可以再看 备份恢复演练指南。
通常是:
- 没带 Bearer token
- token 过期
- token 不是给当前 MCP resource 签发的
- 授权服务器发现链路配置错了
先看 WWW-Authenticate 和资源元数据地址,再确认 resource / audience 是否对得上。
最常见两个方向:
Origin不合法- scope 不足
如果是后者,规范允许服务端在 WWW-Authenticate 里告诉客户端缺哪些 scope。不要把所有 scope 一次性全给,按最小权限逐步抬升更稳。
在远程 MCP 里,404 不一定是路径错了,也可能是 session 已失效。如果请求里带了旧的 MCP-Session-Id,服务端可以直接回 404,客户端应该重新初始化。
常见于:
- 客户端对不支持 SSE stream 的 endpoint 发 GET
- 你自己把代理规则配错,某个 method 被拦了
- 服务端不允许 DELETE 结束 session
一般是请求头不对,比如 JSON-RPC POST 没按要求带正确的 Content-Type 或 Accept。这类问题经常不是服务端逻辑错,而是客户端或代理层把头改坏了。
如果你是新建远程服务,FastMCP 很适合做第一版,原因很简单:示例短、HTTP 部署资料齐、只读工具边界也好控制。
如果你是 Node.js 团队,或者要更细地控制中间件、HTTP transport、OAuth helper,官方 TypeScript SDK 会更灵活。
如果你手里已经有一批本地 stdio 工具,不想重写,再考虑 mcp-proxy 之类桥接方案。但它更像迁移路线,不是本文推荐的新项目默认形态。
还有一个容易踩坑的地方:官方 Python SDK 和 TypeScript SDK 仓库的 main 分支现在都在推进 v2 beta。生产环境真正稳的仍然是 v1.x 线。
- Python SDK:
mainREADME 明确写了 v2 是预发布,生产建议看v1.x - TypeScript SDK:
main也明确写了 v2 beta,生产继续用v1.x
所以你今天上线生产,不要看见 main 分支的新示例就直接照抄。先确认你到底跟的是哪条稳定线、哪套文档、哪个协议版本。
如果你只是本机用工具,继续用 stdio 就行;真要放到 VPS 上给多个客户端远程访问,Streamable HTTP + HTTPS + 标准 OAuth/Bearer + Origin 校验 + 最小权限工具 才是比较稳的主线。
FastMCP、Docker Compose、Caddy 都是很好用的落地选择,但它们解决的是“怎么部署和怎么运维”,不是“协议可以不讲安全”。真正决定你这套远程 MCP 能不能长期跑下去的,还是那几件老实事:
- 工具边界够不够窄
- token audience 校验有没有做
- session 有没有被误当成认证
- 日志、备份、升级有没有制度化
把这些做好,远程 MCP 才会像一个长期服务,而不是一段暂时能跑的 demo。
- MCP Transports(2025-11-25)
https://modelcontextprotocol.io/specification/2025-11-25/basic/transports - MCP Authorization(2025-11-25)
https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization - MCP Security Best Practices(2025-11-25)
https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices - Build MCP Server
https://modelcontextprotocol.io/docs/develop/build-server - Connect Remote Servers
https://modelcontextprotocol.io/docs/develop/connect-remote-servers - Official Python SDK Repository
https://github.com/modelcontextprotocol/python-sdk - Official TypeScript SDK Repository
https://github.com/modelcontextprotocol/typescript-sdk - FastMCP HTTP Deployment
https://gofastmcp.com/deployment/http - FastMCP Authentication
https://gofastmcp.com/servers/auth/
