一、为什么 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 三个硬限制
经验法则:只要“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 为什么生产必须选它
|
跨机器 |
❌ |
✅ |
|
多 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”。
六、选型决策表(直接抄)
|
本地 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。所有计算在浏览器完成,文件不上服务器,关页即清。免费、无登录、无广告,适合开发者当常驻标签页。




