副标题:MCP 专题 · 选型与落地
本文为 vergex.cn 原创首发,转载请注明出处。
把 MCP Server 从 stdio 改成 HTTP 之后回滚的团队,我见过不止一个。原因几乎从来不是协议本身,而是凭据的归属在这次改动里悄悄变了:原来密钥躺在每个使用者的笔记本里,改完之后它必须集中到服务端——而大多数团队在做传输层选型时,根本没把这件事当成一个决策项。
stdio 和 HTTP 的真正分界线不是"本地还是远程",而是凭据放在谁手里、由谁审计。这一条想清楚了,80% 的选型不需要讨论。
一、先数三个数,再选传输方式
在打开 IDE 写代码之前,先回答这三个问题:
- 使用者有几个人? 1 个人和 8 个人是完全不同的两套架构。
- Server 要不要碰生产系统? 只读本地文件和生产数据库的写权限,风险等级差两个数量级。
- 凭据是谁签发的? 是你自己发的长期 Key,还是公司 IdP 发的短期 Token?
只要"使用者 > 1 且要碰生产系统",stdio 基本就出局了。不是因为它技术差,而是因为 stdio 的凭据通道只有一条:客户端配置里的环境变量。这意味着你要把生产密钥复制到每一个使用者的机器上,而且没有轮换机制、没有调用审计、离职时无法回收。
先把这三种形态的取舍摆在一起看:
| 维度 | stdio | HTTP(无状态) | HTTP(有状态) |
| --- | --- | --- | --- |
| 凭据位置 | 使用者本机 env | 服务端 | 服务端 |
| 网络暴露面 | 无 | 需暴露入口 | 需暴露入口 |
| 水平扩容 | 不适用 | 直接加副本 | 需粘性或外部会话存储 |
| 服务端主动推送 | 天然支持 | 不支持 | 支持(SSE + event store) |
| 典型场景 | 单人、本地只读 | 多租户工具网关 / Serverless | 长任务、进度推送 |
注意这张表里没有"性能"这一行。 对于 MCP 这类"LLM 发起、工具执行"的调用模式,传输层的开销几乎从来不是瓶颈,别把精力花在错误的维度上。
二、stdio 落地:两条硬规则
Python 侧用官方 SDK 内置的 FastMCP 就够了:
# server.py
import sys
import logging
from mcp.server.fastmcp import FastMCP
# 关键:日志必须走 stderr
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
log = logging.getLogger("internal-tools")
mcp = FastMCP("internal-tools")
@mcp.tool()
def read_local_config(name: str) -> dict:
"""读取本机 ~/.config 下的一个配置文件(只读)。"""
log.info("read_local_config: %s", name)
...
return {"name": name, "content": "..."}
if __name__ == "__main__":
mcp.run(transport="stdio")
客户端侧(以 Claude Desktop / Cursor 一类本地客户端通用的配置结构为例):
{
"mcpServers": {
"internal-tools": {
"command": "/Users/you/.local/bin/uv",
"args": ["run", "--with", "mcp", "python", "/opt/mcp/server.py"],
"env": { "INTERNAL_API_TOKEN": "..." }
}
}
}
第一条硬规则:stdout 是协议通道,不是你家的日志口。 stdio 传输下,服务器往 stdout 写任何非 JSON-RPC 消息,客户端解析直接崩,而且报错信息通常只显示"连接已关闭",排查成本极高。print()、console.log、tqdm 进度条、某些 HTTP 库的 debug 输出——全是雷。写代码时养成习惯:Python 用 logging 且显式 stream=stderr,Node 用 console.error。
第二条硬规则:command 写绝对路径。 客户端进程不继承你 shell 的 PATH。npx、uvx、node 这些命令在终端里能跑,配到 JSON 里就是 ENOENT。另外首次运行 npx/uvx 会触发下载,如果客户端的初始化超时比较短,会表现为"第一次配置失败、第二次就好了"——建议提前在目标机器上手动跑一次预热包缓存。
三、Streamable HTTP:有状态和无状态必须二选一
自 2025-03-26 版规范起,Streamable HTTP 已经取代了早期的 HTTP+SSE 双端点方案。选型时的核心分叉只有一个:要不要会话。
无状态模式适合 Serverless 和纯工具型网关,每个请求独立,可以随便加副本:
// server.ts
import express from "express";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
const app = express();
app.use(express.json());
function buildServer() {
const server = new McpServer({ name: "internal-tools", version: "1.0.0" });
server.registerTool(
"get_deploy_status",
{
title: "查询部署状态",
description: "查询指定服务最近一次部署状态",
inputSchema: { service: z.string().describe("服务名") },
},
async ({ service }) => ({
content: [{ type: "text", text: JSON.stringify({ service, status: "ok" }) }],
}),
);
return server;
}
app.post("/mcp", async (req, res) => {
try {
const server = buildServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // 无状态:不回写 Mcp-Session-Id
enableJsonResponse: true, // 不用 SSE,直接 JSON 响应
});
res.on("close", () => {
transport.close();
server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (err) {
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: { code: -32603, message: "Internal server error" },
id: null,
});
}
}
});
app.listen(8080, "127.0.0.1");
注意 sessionIdGenerator: undefined 和 enableJsonResponse: true 是配套使用的:无状态模式下没有会话,也就不需要往回推流。这个组合是 Serverless 部署唯一舒服的姿势。
有状态模式则需要真正生成 session id,并处理 Mcp-Session-Id 头。规范要求:客户端在后续请求里带上这个头;如果服务端返回 404,客户端必须重新走一次 initialize。这意味着——多副本 + 内存态会话 = 随机 404。你必须二选一:要么用 cookie/ip_hash 做粘性会话,要么把会话状态外置到 Redis 之类的共享存储。别赌"负载均衡应该会命中同一个副本"。
流式响应一旦过了反向代理,还要多看一眼配置:
location /mcp {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off; # 不开这个,SSE 会被攒着一次性吐出来
proxy_cache off;
gzip off;
proxy_read_timeout 3600s;
proxy_set_header X-Accel-Buffering no;
proxy_set_header Host $host;
}
四、鉴权:本地可以偷懒,远程不行
stdio 场景下不需要 OAuth。 进程是本地的,环境变量就是信任边界。把精力花在"这台机器谁有权限"上,比花在协议层更有效。
一旦走 HTTP,就必须按 OAuth 2.1 来做。 规范的三件套是:资源服务器通过 WWW-Authenticate 头暴露受保护资源元数据(RFC 9728),客户端据此发现授权服务器,令牌通过 Resource Indicators(RFC 8707)绑定到具体资源。
最小可用的 401 响应:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"
对应的元数据文档:
{
"resource": "https://mcp.example.com/mcp",
"authorization_servers": ["https://auth.example.com"],
"bearer_methods_supported": ["header"],
"scopes_supported": ["mcp.tools.read", "mcp.tools.invoke"]
}
服务端校验令牌时,必须校验 audience:
import { createRemoteJWKSet, jwtVerify } from "jose";
const jwks = createRemoteJWKSet(new URL("https://auth.example.com/.well-known/jwks.json"));
export async function requireMcpToken(req, res, next) {
const header = req.header("authorization") ?? "";
const token = header.startsWith("Bearer ") ? header.slice(7) : null;
if (!token) return unauthorized(res);
try {
const { payload } = await jwtVerify(token, jwks, {
issuer: "https://auth.example.com",
audience: "https://mcp.example.com/mcp", // 令牌必须是为本资源签发的
});
req.auth = payload;
next();
} catch {
return unauthorized(res);
}
}
这里的 audience 不是可选项。规范明确要求 MCP Server 不得接受任何不是为它自己签发的令牌——这就是所谓的 token passthrough 反模式,也是最容易被忽略的一条。
对于技术管理者,还有一个现实决策:别自建授权服务器。 复用公司现有 IdP 即可,MCP 侧只需要它支持动态客户端注册(RFC 7591)或者你手动注册一个客户端。自建 AS 的运维成本远高于它带来的收益。
五、避坑:只有踩过才知道的五个故障
1. 无状态和有状态混用。 代码里留着 sessionIdGenerator,部署时又开了 HPA。表现是"大部分请求正常,偶尔 404 Session not found"。这类问题在测试环境单副本时永远复现不了。
2. 反向代理缓冲 SSE。 表现是客户端"卡住不动",直到超时。前面的 nginx 配置里 proxy_buffering off 和 X-Accel-Buffering no 缺一不可,AWS ALB 这类托管 LB 还要额外确认 idle timeout 是否短于你的心跳间隔。
3. Origin 不校验 + 绑定 0.0.0.0。 本地调试时图省事写成 app.listen(8080),等于把带生产权限的 MCP Server 暴露到局域网。规范要求本地 HTTP 服务绑定 127.0.0.1 并校验 Origin 头,防的是 DNS rebinding:攻击者可以用自己的域名解析到 127.0.0.1,让你的浏览器替他发请求。中间件很简单,但必须写:
const ALLOWED_ORIGINS = new Set(["https://app.example.com"]);
app.use((req, res, next) => {
const origin = req.header("origin");
if (origin && !ALLOWED_ORIGINS.has(origin)) return res.status(403).end();
next();
});
4. 把客户端令牌转发给上游 API。 "MCP Server 收到用户令牌 → 直接拿去调内部系统"看起来省事,实际上是混淆代理(confused deputy):上游无法区分"用户本人操作"和"用户授权 MCP 操作",权限被无限放大。正确做法是 MCP Server 用自己的下游凭据换取上游令牌(token exchange),或者干脆用独立的服务账号 + 业务层鉴权。
5. 可恢复流没配 event store。 Streamable HTTP 支持通过 Last-Event-ID 恢复中断的流,但前提是服务端实现了 EventStore 接口。不实现也能跑,只是断线重连时会静默丢消息——业务侧看到的症状是"偶发少一次工具结果",极难归因。
反模式自查表
发布前逐条对一遍,任何一条命中就先别上线:
- [ ] stdio Server 里有
print()/console.log写 stdout - [ ] 客户端配置里
command用的是相对路径或裸命令名 - [ ] 用 HTTP 传输,但没配置 TLS 终止
- [ ] 本地 HTTP 服务绑定在
0.0.0.0且未校验Origin - [ ] 有状态模式 + 多副本 + 内存态会话,且没有粘性策略
- [ ] 服务端接受任何 issuer 签发、任意 audience 的 JWT
- [ ] 把调用方令牌直接透传给上游 API
- [ ] 用长期静态 Key 替代 OAuth,且没有轮换路径
- [ ] 无状态模式下仍返回
Mcp-Session-Id - [ ] 反向代理未关闭 buffering / gzip
- [ ] 使用 SSE 可恢复流但未实现
EventStore - [ ] 没有任何工具调用审计日志(谁、什么时间、调了什么、结果如何)
结论
选型的顺序永远是"凭据归属 → 会话需求 → 传输方式",而不是反过来。 把凭据留在服务端、把会话需求想清楚,stdio 和 HTTP 之间的争论会自动收敛成一个成本问题。
完整的选型对比表(含成本、运维、合规、扩展性四个维度的逐项打分)和一张可以直接贴在白板上用的决策树,已经放在 VergeX 站点的 MCP 专题资源区,做技术评审时可以直接下载对照使用。

