欢迎光临
我们一直在努力

第2讲:Transport 选型——stdio 还是 HTTP?

一、为什么 Transport 是 MCP 设计的第一道分叉

第1讲里我们写了一个 stdio 版的 mcp-server,跑在终端里、和 Claude Code 同机通信很顺。但只要你把视角抬到生产环境,立刻会遇到三个问题:

  • 多个 Agent 实例要共用同一个订单查询工具,stdio 做不到(它是父子进程绑定)
  • K8s 里 MCP Server 要独立部署、独立扩缩容,stdio 没有端口
  • 审计、限流、鉴权要在统一入口做,stdio 没有“入口”

MCP 协议层把“消息格式(JSON-RPC)”和“消息怎么传(Transport)”解耦,正是为了让后端按场景二选一甚至双开。Transport 不是实现细节,是部署边界。

2026-07-28 版规范进一步把 Streamable HTTP 做成协议层无状态:去掉了 initialize 握手、Mcp-Session-Id、GET 长流,每个 POST 自带 _meta 能力声明,任意请求可落任意实例。 这意味着 stdio 和 HTTP 的分工比以往更清晰。


二、stdio:本地父子进程,零网络

2.1 工作机制

Client(Agent Runtime)把 Server 作为子进程拉起,通过 stdin / stdout 按行收发 newline-delimited JSON-RPC。没有端口、没有 TLS、没有鉴权头,凭据靠环境变量继承。

// Claude Desktop / Claude Code 的 mcp 配置
{
"mcpServers": {
"local-time": {
"command": "/opt/mcp-server",
"args": [],
"env": { "TZ": "Asia/Shanghai" }
}
}
}

2.2 适用场景
  • 本地开发、MCP Inspector 调试
  • 桌面端(Claude Desktop)、CLI(Claude Code、Codex CLI)
  • 单机单用户,Server 和 Agent 同机
  • 调本地文件、本地 SQLite、本地脚本
2.3 三个硬限制
  • 网络范围为 0:跨机器不行,容器里 Agent 访问宿主机 stdio Server 要特批
  • 并发模型受限:一个子进程服务一个 Client,多 Agent 要起多进程
  • 可观测性差:没有 HTTP 中间件链,Prometheus 得自己往 stderr 打再采集
  • 经验法则:只要“Server 和 Agent 不在同一个进程树里”,stdio 就出局。


    三、Streamable HTTP:单端点 POST,生产首选

    3.1 2026-07-28 后的形态
    • 服务器只暴露一个端点(如 POST /mcp)
    • 每个 JSON-RPC 消息是一次独立 HTTP POST
    • 响应可以是单条 application/json,也可以是仅针对本次请求的 text/event-stream 流
    • 旧版 GET 长流、Mcp-Session-Id、SSE resumability 全部移除
    • 协议版本/能力声明走 _meta 或 MCP-Protocol-Version / Mcp-Method / Mcp-Name 头

    POST /mcp HTTP/1.1
    Host: mcp.internal.example
    MCP-Protocol-Version: 2026-07-28
    Mcp-Method: tools/call
    Mcp-Name: query_orders
    Authorization: Bearer <agent-workload-token>
    Content-Type: application/json

    {"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"query_orders","arguments":{"user_id":"u_123"},"_meta":{"io.modelcontextprotocol/clientInfo":{"name":"cr-bot","version":"1.0"}}}}

    3.2 为什么生产必须选它

    维度

    stdio

    Streamable HTTP

    跨机器

    多 Client 共享

    ❌(每 Client 一进程)

    ✅(无状态,可轮询 LB)

    水平扩容

    ✅(任意实例处理任意请求)

    TLS / Auth

    进程级

    完整 HTTP 安全模型

    网关 / 限流 / 审计

    标准 HTTP 中间件

    K8s / Serverless

    别扭

    原生

    可观测性

    Prometheus / OTel / 访问日志

    2026-07-28 去掉协议会话后,MCP Server 可以像普通无状态 HTTP 服务一样扔进 round-robin、Cloud Run、Lambda,不需要 Redis 存 session,不需要 sticky session。

    3.3 安全强制项(规范原文)
    • 必须校验 Origin 头,防 DNS rebinding;非法 Origin 返回 403
    • 本地运行只绑 127.0.0.1,别绑 0.0.0.0
    • 生产必须 TLS 1.3 + Bearer / mTLS
    • 通知类消息(无 id)返回 202 Accepted

    四、Go 实现:同一个 Server,双 Transport 切换

    下面把第1讲的 current_time 工具扩成一套核心逻辑 + 两套 Transport。不引第三方 MCP 框架,自己写最小 HTTP 分支,方便你看清机制。

    package main

    import (
    "encoding/json"
    "flag"
    "fmt"
    "log"
    "net/http"
    "os"
    "time"
    )

    // —- 复用第1讲的核心类型 —-
    type ToolSpec struct {
    Name string `json:"name"`
    Description string `json:"description"`
    InputSchema any `json:"inputSchema"`
    }
    type ContentItem struct {
    Type string `json:"type"`
    Text string `json:"text"`
    }
    type ToolResult struct {
    Content []ContentItem `json:"content"`
    }
    type JSONRPCReq struct {
    JSONRPC string `json:"jsonrpc"`
    ID int `json:"id"`
    Method string `json:"method"`
    Params json.RawMessage `json:"params,omitempty"`
    }
    type JSONRPCResp struct {
    JSONRPC string `json:"jsonrpc"`
    ID int `json:"id"`
    Result interface{} `json:"result,omitempty"`
    Error *struct {
    Code int `json:"code"`
    Message string `json:"message"`
    } `json:"error,omitempty"`
    }

    // —- 工具注册表 —-
    var tools = map[string]ToolSpec{
    "current_time": {
    Name: "current_time",
    Description: "返回当前 UTC 与北京时间",
    InputSchema: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
    },
    }

    func callTool(name string, _ json.RawMessage) *ToolResult {
    switch name {
    case "current_time":
    now := time.Now().UTC()
    bj := now.In(time.FixedZone("CST", 8 * 3600))
    return &ToolResult{Content: []ContentItem{
    {Type: "text", Text: fmt.Sprintf("UTC: %s\\n北京: %s", now.Format(time.RFC3339), bj.Format(time.RFC3339))},
    }}
    default:
    return nil
    }
    }

    // —- 公共分发逻辑 —-
    func dispatch(req JSONRPCReq) JSONRPCResp {
    switch req.Method {
    case "initialize":
    return JSONRPCResp{JSONRPC: "2.0", ID: req.ID, Result: map[string]interface{}{
    "protocolVersion": "2026-07-28",
    "capabilities": map[string]interface{}{"tools": map[string]interface{}{}},
    }}
    case "tools/list":
    specs := make([]ToolSpec, 0, len(tools))
    for _, t := range tools {
    specs = append(specs, t)
    }
    return JSONRPCResp{JSONRPC: "2.0", ID: req.ID, Result: map[string]interface{}{"tools": specs}}
    case "tools/call":
    var p struct {
    Name string `json:"name"`
    Arguments json.RawMessage `json:"arguments"`
    }
    json.Unmarshal(req.Params, &p)
    if res := callTool(p.Name, p.Arguments); res != nil {
    return JSONRPCResp{JSONRPC: "2.0", ID: req.ID, Result: res}
    }
    return JSONRPCResp{JSONRPC: "2.0", ID: req.ID, Error: &struct {
    Code int `json:"code"`
    Message string `json:"message"`
    }{Code: -32601, Message: "unknown tool"}}
    default:
    return JSONRPCResp{JSONRPC: "2.0", ID: req.ID, Error: &struct {
    Code int `json:"code"`
    Message string `json:"message"`
    }{Code: -32601, Message: "unsupported method"}}
    }
    }

    // —- stdio Transport —-
    func serveStdio() {
    dec := json.NewDecoder(os.Stdin)
    enc := json.NewEncoder(os.Stdout)
    for {
    var req JSONRPCReq
    if err := dec.Decode(&req); err != nil {
    break
    }
    enc.Encode(dispatch(req))
    }
    }

    // —- Streamable HTTP Transport (2026-07-28 形态) —-
    func serveHTTP(addr string) {
    mux := http.NewServeMux()
    mux.HandleFunc("/mcp", func(w http.ResponseWriter, r *http.Request) {
    // 规范强制:Origin 校验防 DNS rebinding
    if r.Header.Get("Origin") != "" && r.Header.Get("Origin") != "https://mcp.internal.example" {
    http.Error(w, "forbidden origin", http.StatusForbidden)
    return
    }
    if r.Method != http.PostMethod {
    http.Error(w, "only POST", http.StatusMethodNotAllowed)
    return
    }
    var req JSONRPCReq
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
    w.WriteHeader(http.StatusBadRequest)
    return
    }
    resp := dispatch(req)
    // 支持 Accept: text/event-stream 时走 SSE,否则 JSON
    if r.Header.Get("Accept") == "text/event-stream" {
    w.Header().Set("Content-Type", "text/event-stream")
    fmt.Fprintf(w, "data: %s\\n\\n", mustJSON(resp))
    return
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(resp)
    })
    log.Printf("MCP Streamable HTTP listening on %s/mcp", addr)
    log.Fatal(http.ListenAndServe(addr, mux))
    }

    func mustJSON(v interface{}) string {
    b, _ := json.Marshal(v)
    return string(b)
    }

    func main() {
    transport := flag.String("transport", "stdio", "stdio or http")
    addr := flag.String("addr", ":8080", "http listen addr")
    flag.Parse()

    switch *transport {
    case "stdio":
    serveStdio()
    case "http":
    serveHTTP(*addr)
    default:
    fmt.Fprintln(os.Stderr, "unknown transport")
    os.Exit(1)
    }
    }

    运行:

    # 本地 stdio 模式(给 Claude Code 用)
    ./mcp-server -transport stdio

    # 生产 HTTP 模式
    ./mcp-server -transport http -addr :8080
    curl -X POST localhost:8080/mcp -H 'Content-Type: application/json' \\
    -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'


    五、安全分层(L2)

    本讲在 L1(描述不含内部信息)之上补两层:

    • L2(stdio):子进程只继承白名单环境变量(DATABASE_URL、TZ),不继承 SSH_AUTH_SOCK、不继承全部 env;Server 进程以非 root 运行
    • L2(HTTP):
      • 强制 TLS 1.3,HSTS
      • 校验 Origin,非法即 403
      • 本地模式绑 127.0.0.1 而非 0.0.0.0
      • 每个 POST 必须带 Authorization: Bearer,Gateway 层验 workload token 再转发
      • 通知类(无 id)返回 202,不回 JSON-RPC body

    注意:MCP Transport 层不替代业务鉴权。L2 解决“谁能建连”,L3(下一讲起)解决“这个 Agent 能不能调 query_orders”。


    六、选型决策表(直接抄)

    你的场景

    选 stdio

    选 Streamable HTTP

    本地 Claude Desktop / Codex CLI 插件

    MCP Inspector 调试

    ✅(Inspector 也支持)

    单机单用户查本地 DB

    可但没必要

    团队共享工具(订单/用户/通知)

    K8s / 容器化部署

    需要网关限流、审计、scope 鉴权

    Serverless / Cloud Run / Lambda

    ✅(无状态核心天然适配)

    兼容 2025-03 之前旧 Client

    过渡期双协议,逐步退 SSE

    一句话:stdio 是“开发态”,Streamable HTTP 是“生产态”。毕业项目里两个都留,用 -transport 切。


    七、延伸阅读

    • MCP Spec 2026-07-28 / Transports / Streamable HTTP:单端点 POST、去会话、Origin 校验的权威定义
    • Google Cloud 关于 MCP 无状态化的工程说明:为什么去掉 Mcp-Session-Id 后 LB 不再需要 sticky
    • MCPTox 基准(2026):Transport 层之外,工具描述注入仍是主要攻击面

    八、下一讲预告

    第3讲:工具设计——从 API 到“模型友好”的工具面

    Transport 解决了“怎么传”,下一讲解决“传什么”:工具名、description、JSON Schema 参数怎么写模型才不误用;参数校验为什么要在 Server 端做两次(schema 层 + 业务语义层);细粒度工具 vs 粗粒度工具的取舍。会拿 query_orders 做正反例对照。


    🧰 开发之余的小工具推荐

    处理 Base64、JSON 格式化、JWT 解析、Crontab 计算、PDF 合并压缩这些碎片需求,我常用一个纯前端本地工具箱:zz365.top。所有计算在浏览器完成,文件不上服务器,关页即清。免费、无登录、无广告,适合开发者当常驻标签页。

    赞(0)
    未经允许不得转载:171主机测评 » 第2讲:Transport 选型——stdio 还是 HTTP?
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址