Agent · MCP 协议(Model Context Protocol)

AI 应用的 USB-C — 一次握手, 把任意 server 的工具/资源/模板标准化接进你的 agent

MCP = AI 应用的 USB-C: 一个协议把工具/数据/模板标准化接入, Host 聚合多个 server 的能力统一给模型 stdio HTTP+OAuth MCP Host — 你的 Agent 应用 MCP Client · initialize 握手+版本协商 · tools/list 发现能力 · tools/call 转发调用 · 聚合多 server 统一工具表 1 client ↔ N server, 协议适配收口在一处 模型只见"工具清单", 不见 server 边界 本地 Server — stdio 子进程 filesystem / sqlite / git tools: read_file, query resources: file:// 内容 prompts: /review 模板 同机进程 · 免认证 · 低延迟 远程 Server — Streamable HTTP GitHub · SaaS API tools: create_issue, ... OAuth 2.1 · 跨网多租户 水平扩展 · 版本灰度 三原语 · 谁控制? tools — 模型控制 resources — 应用控制 prompts — 用户控制 同一 server 可同时暴露三者; 归属决定"谁决定何时使用" 事故现场 stdio server 里 print('debug') → stdout 污染 JSON-RPC 通道 client: Invalid Request 崩溃 日志只走 stderr, stdout 是协议专线 握手时序 Handshake — 所有消息都是 JSON-RPC 2.0: {jsonrpc, id, method, params} ① initialize 协议版本 + capabilities 协商 双方各自声明支持什么 ② tools/list 发现 server 能力清单 工具 schema + cursor 分页 ③ tools/call client 转发模型的调用 返回 content 或 isError ④ notifications tools/list_changed 通知 能力变化 → 触发重新发现 stdio 传输: 换行分隔帧, 进程内 0 网络开销; Streamable HTTP: 请求响应 + SSE 流, 需 OAuth 鉴权与会话管理

协议本质(机制视角)

  • • 三原语按"控制权"划分: tools 模型控 / resources 应用控 / prompts 用户控
  • • 全部消息 JSON-RPC 2.0, initialize 先行
  • • 1 client ↔ N server, 能力聚合后模型无感

两种传输(部署视角)

  • • stdio: 本地子进程, 免认证零延迟, 适合个人工具
  • • Streamable HTTP: 跨网多租户, OAuth 2.1 鉴权
  • • stdout 是协议专线, 日志只许走 stderr

工程边界(生产价值)

  • • 工具注解(readOnlyHint/destructiveHint)驱动权限策略
  • • list_changed 通知 → 动态重发现, 不用重启
  • • server 只做"能力封装", 业务编排留在 host

💡 一句话理解

MCP 解决的是 M×N 接口爆炸: M 个 AI 应用对接 N 种工具, 过去要写 M×N 个胶水层; 有了 MCP, 两边都只实现一次协议, 变成 M+N。它像 USB-C: 设备(工具提供方)与电脑(Agent 应用)各出一个标准口, 插上即用——tools 是插上就能被模型调用的"外设", resources 是可被应用挂载的"移动硬盘", prompts 是预设的"快捷键"。学会这一页, 你就能自己写 server 把公司内部系统接进任何 agent。

🧠 必知必会 必考 & 必会

MCP 是什么
Model Context Protocol, 2024-11 由 Anthropic 开源, 2025 年成为事实标准。把"给模型接工具"这件事标准化成协议, 而不是每家一套私有插件格式。
# 没有协议: 每个应用×每个工具都要写适配
glue = apps * tools          # → M×N 份胶水代码
# 有了 MCP: 双方各实现一次协议
glue = apps + tools          # → M+N 份, 复用即插即用
三原语归属
tools 由模型决定何时调用; resources 由应用决定注入什么上下文; prompts 由用户显式触发。归属不同, 权限策略也不同。
tools     # → 模型自主调用: 需要参数校验+权限闸门
resources # → App 决定读什么: file://orders/42 注入上下文
prompts   # → 用户输入 /review 触发: 模板由 server 提供
stdio 传输
本地模式: client 把 server 作为子进程启动, stdin/stdout 跑 JSON-RPC, 每行一帧。零网络开销、天然同机权限。
{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}
# ↑ client 写进 server 的 stdin, server 从 stdout 回一行 JSON
# 关键: stdout 是协议专线, print 调试日志 = 毁掉通道
Streamable HTTP
远程模式: 单个 HTTP 端点, 普通 POST 请求响应 + 必要时 SSE 流式返回。配 OAuth 2.1 鉴权, 支持无状态横向扩展。
POST /mcp
Authorization: Bearer <token>
# → Content-Type: text/event-stream (流式) 或 application/json
# 关键: 会话用 Mcp-Session-Id 头关联, 重启会丢
initialize 握手
会话第一条消息: 双方交换 protocolVersion 与 capabilities。capabilities 决定"这个 server 能干什么", 没声明的能力 client 不会用。
{"method": "initialize",
 "params": {"protocolVersion": "2025-06-18",
            "capabilities": {"tools": {}, "resources": {}}}}
# 关键: 忘声明 capabilities → client 直接不发起对应请求
tools/list 发现
client 初始化后拉取工具清单, 拿到 name/description/inputSchema, 转换成模型可用的 tools 参数。清单可分页。
page = await session.list_tools()
page.tools[0].name          # → "query_orders"
page.tools[0].inputSchema   # → JSON Schema, 转成模型 tools 参数
page.nextCursor             # → 有值就继续翻页, 否则漏一半
tools/call 返回
调用返回 content 数组(text/image/resource)与 isError。业务失败用 isError=true + 文本说明, 让模型读到失败原因。
result = await session.call_tool("query_orders", {"status": "paid"})
result.isError              # → False
result.content[0].text      # → '[{"order_id": "A102", ...}]'
工具注解 hints
注解是给 host 的权限策略提示, 不是给模型的: readOnlyHint(只读)/destructiveHint(破坏性)/idempotentHint(幂等)。
# readOnlyHint=true → host 可以放心在自动模式放行
# destructiveHint=true → host 应挂起等人工确认
# 关键: 注解是"提示"不是"保证", 高危操作仍要服务端校验
OAuth 2.1 授权
远程 server 是第三方, 用户的 GitHub token 不该交给 host。OAuth 流程让 host 拿到受限 token, server 校验后按最小权限放行。
401 + WWW-Authenticate → client 引导用户授权
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
# 关键: token 过期要能触发重新授权, 而不是静默 401 循环
roots 作用域
client 用 roots 告诉 server "你只能在这些目录/URI 范围内活动", 是文件类 server 的核心安全边界。
roots = ["file:///Users/luca/ZhongQiuProject"]
# server 侧: 每次文件操作前校验路径前缀
# user 传 "../etc/passwd" → 拒绝: 越出 root 范围
sampling 反向请求
server 也能反过来请 client 的 LLM 生成内容(如总结), 称 sampling。用户可审批——能力下沉给 server, 生成权仍在用户侧。
# server: "帮我总结这段 diff"
sampling/createMessage → client 弹窗确认 → LLM 生成 → 回给 server
# 关键: sampling 让 server 不必自带 API key
list_changed 通知
server 工具集变化时发 notifications/tools/list_changed, client 重新 list。热更新不重启。
# server 新增工具后:
notifications/tools/list_changed    # → client 重拉 tools/list
# 不发通知 → 新工具永远不出现在模型面前
stdout 纪律
stdio 模式下 stdout 是 JSON-RPC 专线: 任何 print/print_r/库日志都会污染通道。日志一律走 stderr, 或写文件。
print("query took 0.3s")   # → client: Invalid Request: 解析失败
logging.basicConfig(stream=sys.stderr)   # 对: 日志走 stderr

🏭 生产实战 real world

场景 1 · 用 FastMCP 写只读订单 server(20 行上线)

客服 agent 要查订单库, 但不能给写权限。FastMCP 装饰器把普通函数一键注册成带 Schema 的工具。

from mcp.server.fastmcp import FastMCP

mcp = FastMCP("orders")

@mcp.tool()
def query_orders(status: str, limit: int = 20) -> str:
    """按状态查询订单, 返回 JSON 数组。status: pending/paid/shipped"""
    rows = db.fetch("SELECT id, amount, status FROM orders"
                    " WHERE status = %s LIMIT %s", (status, limit))
    return json.dumps(rows, ensure_ascii=False)

if __name__ == "__main__":
    mcp.run()      # 默认 stdio 传输, 日志记得走 stderr

场景 2 · stdio 本地接入: 桌面/CLI 客户端配置

本地 server 靠一份 JSON 声明接入。command+args+env 三要素, 密钥从 env 注入而不是写死在代码里。

// claude_desktop_config.json / 通用 MCP 客户端配置
{
  "mcpServers": {
    "orders": {
      "command": "uv",
      "args": ["run", "orders_server.py"],
      "env": {"DB_DSN": "postgres://readonly:***@10.0.0.3/orders"}
    }
  }
}
// 关键: DB 账号用 readonly 角色 — server 被攻破也写不了库

场景 3 · 远程 server: Streamable HTTP 挂到 FastAPI

给全团队的共享 server 要跨网部署: streamable-http 传输 + 反代 + 域名, 一处部署处处接入。

mcp = FastMCP("orders", host="0.0.0.0", port=8808)
# ...工具注册同上...
mcp.run(transport="streamable-http")
# 客户端侧连接:
params = {"url": "https://mcp.internal.corp/orders",
          "headers": {"Authorization": f"Bearer {token}"}}
# nginx 反代记得: proxy_buffering off;  SSE 流不能被缓冲

场景 4 · 客户端侧: 初始化→发现→调用 三连

host 侧集成 MCP 的标准流程, 全异步。initialize 必须先于一切。

from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

params = StdioServerParameters(command="uv", args=["run", "orders_server.py"])
async with stdio_client(params) as (read, write):
    async with ClientSession(read, write) as session:
        await session.initialize()                # ① 握手, 必须第一
        tools = await session.list_tools()        # ② 发现
        result = await session.call_tool(         # ③ 调用
            "query_orders", {"status": "pending"})
        return result.content[0].text

场景 5 · resources: 把文件暴露为可挂载上下文

大文档不进工具参数, 做成 resource 由应用按需挂载——注入什么由 App 决定, 不是模型。

@mcp.resource("file://orders/{order_id}")
def order_detail(order_id: str) -> str:
    """单个订单的完整档案, 供应用按需注入"""
    return render(order_detail_md(order_id))

# host 侧:
contents = await session.read_resource("file://orders/A102")
# → 应用决定塞进上下文还是丢弃, 模型只看到最终上下文

场景 6 · prompts: 团队共享的斜杠命令模板

把"代码评审""写周报"沉淀成 server 端模板, 全团队 agent 都能 /review 直呼。

@mcp.prompt()
def review(code: str, focus: str = "正确性与边界条件") -> str:
    """生成一次代码评审请求"""
    return f"""请评审以下代码, 重点关注 {focus}:
{code}
输出: 问题清单(按严重度排序) + 修复建议"""

# 用户输入 /review → client 列出该模板 → 按参数填充后发送

场景 7 · 工具注解: 给 host 的权限信号

host 的自动放行策略不能拍脑袋, 靠 server 显式声明只读/破坏性, 再配白名单策略。

from mcp.types import ToolAnnotations

@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
def query_orders(status: str) -> str: ...

@mcp.tool(annotations=ToolAnnotations(destructiveHint=True,
                                     idempotentHint=False))
def purge_cache(pattern: str) -> str: ...
# host 策略: readOnly 自动放行; destructive 一律挂起等审批

场景 8 · OAuth 2.1 保护远程 server

server 校验 Bearer token 的 issuer/audience/scope, 401 响应带授权指引, 而不是裸奔。

# server 侧每个请求:
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
    return JSONResponse(status_code=401, headers={
        "WWW-Authenticate": f'Bearer resource_metadata='
                            f'"https://mcp.corp/.well-known/oauth-protected-resource"'})
claims = jwt.decode(auth[7:], JWKS, audience="mcp-orders")
# 校验 iss/aud/exp/scope, 缺一不可

场景 9 · 生产部署: Docker + 自动重启 + 健康检查

server 进程挂了工具表就失效。容器化部署配重启策略与健康检查, host 侧重连有退避。

# docker-compose.yml
services:
  mcp-orders:
    build: ./mcp-orders
    command: ["python", "server.py"]   # streamable-http 模式
    restart: unless-stopped             # 崩溃自动拉起
    healthcheck:
      test: ["CMD", "curl", "-sf", "http://localhost:8808/health"]
      interval: 30s
      timeout: 3s
    environment:
      - DB_DSN=postgres://readonly:***@db/orders
      - MCP_AUTH_AUDIENCE=mcp-orders

场景 10 · 多 server 聚合: 命名空间防冲突

host 同时接 5 个 server, 三个都有 get_user。注册时统一加前缀, 冲突启动即报错。

def aggregate(servers: dict[str, McpClient]):
    registry = {}
    for name, client in servers.items():
        for t in client.list_tools():
            fq = f"{name}__{t.name}"          # github__get_user
            if fq in registry:
                raise ToolConflict(fq)         # 冲突启动即失败, 别带病上线
            registry[fq] = (client, t)
    return registry   # 模型看到的每个工具都可追溯到唯一 server

⚠️ 编码注意与常见坑 pitfalls

坑 1 · print 污染协议通道 — 症状: client 报 Invalid Request, 连接即崩. 原因: stdio server 用 print 打调试日志, 混进 JSON-RPC 帧. 正解: 日志一律走 stderr。
# 错: print("cache hit")            # → stdout 混入非 JSON 行, 解析崩
# 对: logging.basicConfig(stream=sys.stderr)
坑 2 · 忘声明 capabilities — 症状: server 明明实现了 tools, client 却说"没有工具". 原因: initialize 应答没带 tools capability. 正解: capabilities 与实现保持同步。
# 错: capabilities: {}              # → client 不发起 tools/list
# 对: capabilities: {"tools": {}, "resources": {}}
坑 3 · 协议版本硬编码 — 症状: 升级 client 后连接被拒. 原因: 写死旧 protocolVersion, 不做协商. 正解: 从对方请求里回显双方都支持的版本。
# 错: return "2024-11-05"           # → client 要求 2025-06-18, 握手失败
# 对: return negotiated(requested_version)
坑 4 · 长任务无进度通知 — 症状: 大导出任务 client 超时断开. 原因: 10 分钟的活不回进度. 正解: 用 progress notification 汇报进度。
# 错: 跑 10 分钟一声不吭           # → client 60s 超时, 任务白跑
# 对: await ctx.report_progress(40, 100)  # 阶段性汇报
坑 5 · 大文件塞 resources — 症状: 一挂载 50MB 日志, 上下文瞬间爆. 原因: resource 无脑返回全文. 正解: resource 返回摘要+分页, 全文走工具按需取。
# 错: return open(f).read()          # → 50MB 注入上下文
# 对: return head(f, 200) + "\n...(用 read_lines 工具分段读)"
坑 6 · 远程 server 无认证 — 症状: 内网工具 server 被公网扫描器整个拖走. 原因: 裸 HTTP 上线. 正解: OAuth 2.1 + 网络层白名单。
# 错: app.run(host="0.0.0.0")       # → 无鉴权裸奔公网
# 对: Bearer 校验 + securityGroup 限源
坑 7 · handler 同步阻塞 — 症状: 一个慢查询拖死全部工具. 原因: async 框架里跑同步 DB 调用, 卡死事件循环. 正解: 用 async 驱动或 to_thread。
# 错: rows = psycopg2.cursor().fetchall()  # → 事件循环卡 5s
# 对: rows = await asyncio.to_thread(sync_query, sql)
坑 8 · 忽略 cursor 分页 — 症状: server 有 60 个工具, host 只看到 30 个. 原因: list_tools 只拿第一页. 正解: 按 nextCursor 翻完为止。
# 错: tools = (await session.list_tools()).tools  # → 只有第一页
# 对: while page.nextCursor: page = await list_tools(cursor)
坑 9 · 工具名跨 server 冲突 — 症状: 同名工具永远只有一家生效. 原因: 聚合时后者覆盖前者且无告警. 正解: 命名空间前缀 + 启动冲突检查。
# 错: registry[t.name] = client      # → 静默覆盖
# 对: fq = f"{server}__{t.name}"; 冲突即 raise
坑 10 · 盲信第三方工具描述 — 症状: agent 被"一个 server"指挥去发邮件. 原因: 恶意 server 在 description 里写诱导指令(tool poisoning). 正解: 工具描述视为数据, 权限闸门不豁免。
# 错: 第三方 server 工具无条件信任 + 全权限
# 对: 未知来源 server → 沙箱权限 + 每次调用审计
坑 11 · 密钥明文进配置 — 症状: config 提交到 git, 数据库密码泄漏. 原因: env 值写死在配置文件. 正解: 配置里放变量引用, 运行时注入。
// 错: "env": {"DB_DSN": "postgres://user:p@ssw0rd@..."}
// 对: "env": {"DB_DSN": "${DB_DSN}"}   // 真值在环境/密钥管理
坑 12 · Windows 文本模式换行 — 症状: Windows 上 server 输出的 JSON 帧全解析失败. 原因: 文本模式把 \n 写成 \r\n, 帧分隔错乱. 正解: 以二进制模式写 stdout。
# 错: sys.stdout.write(frame)        # → Windows 下 \r\n 污染帧
# 对: sys.stdout.buffer.write(frame.encode() + b"\n")
坑 13 · server 崩溃无拉起 — 症状: 半夜 server OOM, 全部工具变"查无此工具". 原因: 子进程无重启策略. 正解: 容器 restart 策略 + host 侧重连退避。
# 错: spawn 后不监控                # → 挂了就是永久失效
# 对: restart: unless-stopped + client 指数退避重连
坑 14 · 返回无结构大 JSON — 症状: 一次调用回填 80k tokens. 原因: 工具把整表序列化返回. 正解: 瘦身字段 + 截断 + 分页, 与普通工具同一纪律。
# 错: return json.dumps(all_rows)    # → 80k tokens 入上下文
# 对: return json.dumps([slim(r) for r in rows[:20]])
坑 15 · server 端不校验参数 — 症状: 工具参数里带 '; DROP TABLE, 直通 SQL. 原因: 以为"是 MCP 就安全", server 内没闸门. 正解: server 内照常 pydantic 校验 + 参数化查询。
# 错: db.execute(f"SELECT ... {args['q']}")  # → 注入直达
# 对: db.execute("SELECT ... WHERE q = %s", (validated.q,))
坑 16 · 不发 list_changed — 症状: 运维给 server 加了新工具, 用户永远用不上. 原因: 工具表变更没发通知, host 缓存不失效. 正解: 变更即通知, host 重拉。
# 错: 注册新工具后闷声不响      # → host 缓存里没有它
# 对: notify("notifications/tools/list_changed")
坑 17 · 业务逻辑塞进 server — 症状: 改个业务规则要发布 5 个 server. 原因: 把编排/策略写进了工具层. 正解: server 只做"能力封装", 编排与策略留在 host。
# 错: server 里写"退款必须先查风控"业务流
# 对: server 暴露 check_risk + refund 两个原子工具, 流程归 host
坑 18 · 每次 call 重新 initialize — 症状: 每个工具调用多 300ms 延迟. 原因: 客户端封装把 initialize 写进了 call 路径. 正解: 会话复用, initialize 一次后长连。
# 错: async with connect(): await call_one_tool()  # 每次新建会话
# 对: 会话保活 + 断线重连, initialize 只在握手时发生
坑 19 · resource URI 不可枚举 — 症状: 应用不知道有哪些 resource 可挂. 原因: 只实现了 uriTemplate 读, 没实现列表. 正解: 实现 resources/list, 模板与列表一致。
# 错: 只支持 file://orders/{id} 直读, 无法发现
# 对: resources/list 返回可枚举清单 + 模板双通道
坑 20 · stderr/stdout 搞反 — 症状: "明明按规范来了还是崩". 原因: 日志打到了 stdout, 或把协议帧打到了 stderr 排障时看不见. 正解: 明确分工: stdout=协议, stderr=日志。
# 错: sys.stderr.write(jsonrpc_frame)   # → 协议帧进了日志黑洞
# 对: stdout 写帧, stderr 写日志, 单测双流各断言