MCP Server 选型对比:stdio / HTTP / 鉴权怎么定

把 MCP Server 从 stdio 改成 HTTP 之后回滚的团队,我见过不止一个。原因几乎从来不是协议本身,而是凭据的归属在这次改动里悄悄变了:原来密钥躺在每个使用者的笔记本里,改完之后它必须集中到服务端——而大多数团队在做传输层选型时,根本没把这件事当成一个决策项。

副标题: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 专题资源区,做技术评审时可以直接下载对照使用。

技术深度解析

上下文工程实战:让 Agent 不跑偏的 6 个手法

2026-10-5 9:51:36

技术深度解析

本地跑大模型的成本账:显卡、量化与真实账单

2026-10-11 15:35:49

0 条回复 A文章作者 M管理员
VergeX|科技前沿
    暂无讨论,说说你的看法吧
❯
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索