MCPServer对接 OAuth 授权---AI翻译系列
基于 MCP 协议规范 + OAuth 2.1,从原理到完整调用流程,涵盖多客户端多服务端场景。
目录
1. 核心角色与概念
1.1 四个角色
| 角色 | 说明 | 类比 |
|---|---|---|
| 用户(Resource Owner) | 资源的所有者 | 保险柜的主人 |
| MCP Client | 调用工具的 AI 应用(ChatBot 等) | 需要取资料的朋友 |
| MCP Server | 提供工具能力,代理用户访问资源 | 银行前台 |
| OAuth Server | 验证用户身份,发放 token | 银行安保系统 |
1.2 通信协议
-
协议:JSON-RPC 2.0
-
传输方式:
-
stdio:子进程 stdin/stdout(本地场景)
-
Streamable HTTP:HTTP POST + 可选 SSE 流(远程场景)
-
-
请求响应匹配:靠 JSON-RPC 的
id字段,即使乱序也能匹配
1.3 关键标识
| 标识 | 生成方 | 用途 |
|---|---|---|
Mcp-Session-Id |
Server 生成,通过 Response Header 发给 Client | 标识一个连接会话 |
access_token |
OAuth Server 发放 | 访问真实资源 API(短期,1小时) |
refresh_token |
OAuth Server 发放 | 换新 token(长期,30天) |
code |
OAuth Server 生成 | 一次性授权码,用于换 token |
mcp_token |
Server 自己签发 | Client 访问 Server 的受限凭证(可选,简化版可省略) |
2. 为什么 Token 给 Server 而不是 Client
2.1 传统 OAuth vs MCP OAuth
传统 OAuth(如"用微信登录第三方网站"):
用户授权 → Client 拿到 token → Client 直接拿 token 去调 API
问题:Client 手里有"万能钥匙",能反复直接访问
MCP OAuth:
用户授权 → MCP Server 拿到 token → Client 拿到的不是 token,而是"访问 MCP Server 的凭证"
好处:Client 永远接触不到真正的 OAuth token
2.2 设计原因
-
安全:Client 不可信(可能是第三方写的、可能被反编译),token 给 Server 才能控制权限范围
-
最小权限:OAuth token 可能权限很大,Server 只给 Client 暴露"搜索工具"、"读取文件"等有限能力
-
可审计:所有真实 API 调用都经过 Server,Server 知道谁在什么时候调了什么
3. 完整调用流程(从零开始)
阶段 1:建立连接 + 握手(initialize)
没有任何前置条件,Client 直接发请求。
Client 发送:
POST /mcp HTTP/1.1
Host: mcp-server.com
Content-Type: application/json
Accept: application/json, text/event-stream
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {
"name": "my-chatbot",
"version": "1.0.0"
}
}
}
Server 响应:
HTTP/1.1 200 OK
Content-Type: application/json
Mcp-Session-Id: sess-aaa-111 ← Server 分配
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {},
"resources": {}
},
"serverInfo": {
"name": "file-search-server",
"version": "2.0.0"
}
}
}
Server 内部操作:
sessions = {}
def handle_initialize(request):
session_id = generate_uuid() # → "sess-aaa-111"
sessions[session_id] = {
"id": "sess-aaa-111",
"created_at": "2025-07-01T10:00:00Z",
"user": None, # 还不知道是谁
"oauth_token": None, # 还没授权
"refresh_token": None,
"status": "connected", # 已连接但未授权
"protocol_version": "2024-11-05"
}
return Response(
json={"result": { ... }},
headers={"Mcp-Session-Id": session_id}
)
Client 内部操作:
class MCPClient:
def __init__(self, server_url):
self.server_url = server_url
self.session_id = None
def connect(self):
response = http_post(self.server_url, json={
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "my-chatbot", "version": "1.0.0"}
}
})
self.session_id = response.headers["Mcp-Session-Id"]
# → "sess-aaa-111"
此时 Session 表状态:
Server 的 Session 表:
┌────────────────┬──────────┬─────────┬───────────┬────────────┐
│ Session-Id │ 状态 │ 用户 │ OAuth Token│ Refresh │
├────────────────┼──────────┼─────────┼───────────┼────────────┤
│ sess-aaa-111 │ connected│ (无) │ (无) │ (无) │
└────────────────┴──────────┴─────────┴───────────┴────────────┘
Client 存储:
session_id = "sess-aaa-111"
阶段 2:Client 尝试调用工具(未授权)
Client 发送:
POST /mcp HTTP/1.1
Mcp-Session-Id: sess-aaa-111
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "search_drive",
"arguments": { "query": "年度报告" }
}
}
HTTP/1.1 401 Unauthorized
Mcp-Session-Id: sess-aaa-111
{
"jsonrpc": "2.0",
"id": 2,
"error": {
"code": 401,
"message": "未授权,请先完成 OAuth 授权",
"data": {
"authorization_url": "https://oauth.server.com/authorize?client_id=mcp-srv&redirect_uri=http://mcp-server.com/callback&scope=read:files&state=sess-aaa-111"
}
}
}
此时 Session 表状态(未变化):
┌────────────────┬──────────┬─────────┬───────────┬────────────┐
│ Session-Id │ 状态 │ 用户 │ OAuth Token│ Refresh │
├────────────────┼──────────┼─────────┼───────────┼────────────┤
│ sess-aaa-111 │ connected│ (无) │ (无) │ (无) │
│ │ 未授权 │ │ │ │
└────────────────┴──────────┴─────────┴───────────┴────────────┘
阶段 3:OAuth 授权(浏览器参与)
步骤 1:Client 打开浏览器
═══════════════════════════════════════════════════════
auth_url = "https://oauth.server.com/authorize?" + urlencode({
"client_id": "mcp-srv",
"redirect_uri": "http://mcp-server.com/callback", ← 提前注册的白名单地址
"scope": "read:files",
"state": "sess-aaa-111", ← 绑定 Session
"response_type": "code"
})
webbrowser.open(auth_url)
步骤 2:用户在 OAuth Server 登录 + 点同意
═══════════════════════════════════════════════════════
浏览器 ──GET──▶ https://oauth.server.com/authorize?...&redirect_uri=http://mcp-server.com/callback
OAuth Server 验证:
✓ client_id 存在
✓ redirect_uri 在白名单中
✓ scope 合法
步骤 3:OAuth Server 302 重定向
═══════════════════════════════════════════════════════
OAuth Server ──302──▶ 浏览器
Location: http://mcp-server.com/callback?code=auth-code-xyz&state=sess-aaa-111
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
就是 Client 之前写在 redirect_uri 里的地址
步骤 4:浏览器访问 callback
═══════════════════════════════════════════════════════
浏览器 ──GET──▶ http://mcp-server.com/callback?code=auth-code-xyz&state=sess-aaa-111
步骤 5:MCP Server 拿 code 换 token(后端对后端)
═══════════════════════════════════════════════════════
MCP Server ──POST──▶ https://oauth.server.com/token
{
"grant_type": "authorization_code",
"code": "auth-code-xyz",
"client_id": "mcp-srv",
"client_secret": "my-secret-key",
"redirect_uri": "http://mcp-server.com/callback"
}
OAuth Server ──▶ MCP Server
{
"access_token": "eyJhbG...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "refresh-xyz"
}
步骤 6:Server 更新 Session
═══════════════════════════════════════════════════════
session = sessions["sess-aaa-111"]
session["oauth_token"] = "eyJhbG..."
session["refresh_token"] = "refresh-xyz"
session["user"] = decode_token(...)["user"]
session["status"] = "authorized"
授权后 Session 表状态:
┌────────────────┬────────────┬──────────────┬───────────────┬──────────────┐ │ Session-Id │ 状态 │ 用户 │ OAuth Token │ Refresh │ ├────────────────┼────────────┼──────────────┼───────────────┼──────────────┤ │ sess-aaa-111 │ authorized │ 张三(user-001)│ eyJhbG... │ refresh-xyz │ └────────────────┴────────────┴──────────────┴───────────────┴──────────────┘
阶段 4:Client 怎么知道授权成功了
方式 1:轮询(最常用)
def call_tool_with_auth_retry(self, tool_name, args, max_retries=20):
for i in range(max_retries):
response = http_post(self.server_url,
headers={"Mcp-Session-Id": self.session_id},
json={"method": "tools/call", "params": {"name": tool_name, "arguments": args}}
)
if response.status == 200:
return response.json() # 成功了
if response.status == 401:
if i == 0:
auth_url = response.json()["error"]["data"]["authorization_url"]
webbrowser.open(auth_url)
print("请在浏览器中完成授权...")
time.sleep(3) # 等 3 秒再试
continue
raise Exception("授权超时")
时间线: t=0s Client 发请求 → 401 → 打开浏览器 → 开始轮询 t=3s Client 发请求 → 401 → 继续等 t=6s Client 发请求 → 401 → 继续等 t=8s 用户在浏览器点同意 → token 存入 Session t=9s Client 发请求 → 200 → 成功!停止轮询
方式 2:WebSocket/回调通知(适合 Web 应用)
MCP Server ──WebSocket──▶ Client: { "type": "auth_success", "session_id": "sess-aaa-111" }
阶段 5:Client 调用工具(已授权)
Client 发送:
POST /mcp HTTP/1.1
Mcp-Session-Id: sess-aaa-111
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "search_drive",
"arguments": { "query": "年度报告" }
}
}
Server 处理:
def handle_tool_call(request):
session_id = request.headers["Mcp-Session-Id"]
session = sessions[session_id]
if session["status"] == "authorized":
# 用 token 去调真正的资源 API
api_response = http_get(
"https://api.drive.com/v3/files?q=年度报告",
headers={"Authorization": f"Bearer {session['oauth_token']}"}
)
return Response(json={
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [{"type": "text", "text": "找到文件:年度报告.pdf"}]
}
})
Server 响应:
HTTP/1.1 200 OK
Mcp-Session-Id: sess-aaa-111
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [{"type": "text", "text": "找到文件:年度报告.pdf"}]
}
}
完整时序图
Client Browser MCP Server OAuth Server │ │ │ │ │ 1.initialize │ │ │ │─────────────────────▶│(经过Server) │ │ │ │ │ │ │ 2.200 + Session-Id │ │ │ │◀─────────────────────│ │ │ │ │ │ │ │ 3.tools/call │ │ │ │──sess-aaa───────────▶│ │ │ │ │ │ │ │ 4.401 + auth_url │ │ │ │◀─────────────────────│ │ │ │ │ │ │ │ 5.打开浏览器 │ │ │ │─────────────────────▶│ │ │ │ │ 6.登录+授权 │ │ │ │────────────────────────────────────────────▶│ │ │ │ │ │ │ 7.302 → callback │ │ │ │◀────────────────────────────────────────────│ │ │ │ │ │ 8.轮询(等3秒) │ 9.访问 callback │ │ │─────────────────────▶│─────────────────────▶│ │ │ │ │ │ │ │ │ 10.code换token │ │ │ │─────────────────────▶│ │ │ │ │ │ │ │ 11.access_token │ │ │ │◀─────────────────────│ │ │ │ │ │ │ 12.HTML 成功页面 │ ← Session 状态变为 │ │ │◀────────────────────│ authorized │ │ │ │ │ │ 13.tools/call │ │ │ │──sess-aaa───────────▶│ │ │ │ │ │ 14.用token调API │ │ │ │─────────────────────▶│ │ │ │ │ │ │ │ 15.返回数据 │ │ │ │◀─────────────────────│ │ │ │ │ │ 16.200 + 结果 │ │ │ │◀─────────────────────│ │ │
4. Session 的生命周期与状态变化
4.1 Session 状态机
Client 第一次连接
┌──────────────────────────────────────┐
│ ▼
┌─────────┐ OAuth 授权成功 ┌─────────────┐ Token 过期 ┌──────────┐
│ 已连接 │ ──────────────────▶ │ 已授权 │ ──────────────────▶ │ 需刷新 │
│ connected│ │ authorized │ │ refreshing│
└─────────┘ └─────────────┘ └──────────┘
│ │ │
│ 用户主动撤销 │ refresh_token 也过期 │ 刷新成功
▼ ▼ ▼
┌─────────┐ ┌─────────────┐ ┌─────────────┐
│ 已撤销 │ │ 需重新授权 │ │ 已授权 │
│ revoked │ │ re-auth │ │ authorized │
└─────────┘ └─────────────┘ └─────────────┘
4.2 各阶段 Session 表内容变化
阶段 0:初始(无连接)
┌──────────┬──────┬────┬───────┬────────┐
│ Session │ 状态 │用户│ Token │ Refresh│
├──────────┼──────┼────┼───────┼────────┤
│ (空) │ │ │ │ │
└──────────┴──────┴────┴───────┴────────┘
阶段 1:Client 连接,分配 Session-Id
┌────────────┬───────────┬──────┬───────┬────────┐
│ Session-Id │ 状态 │ 用户 │ Token │ Refresh│
├────────────┼───────────┼──────┼───────┼────────┤
│ sess-aaa │ connected │ (无) │ (无) │ (无) │
└────────────┴───────────┴──────┴───────┴────────┘
阶段 2:OAuth 授权成功
┌────────────┬────────────┬─────────────┬──────────┬────────────┐
│ Session-Id │ 状态 │ 用户 │ Token │ Refresh │
├────────────┼────────────┼─────────────┼──────────┼────────────┤
│ sess-aaa │ authorized │ 张三(user-001)│ tok-aaa │ ref-aaa │
└────────────┴────────────┴─────────────┴──────────┴────────────┘
阶段 3:access_token 过期,用 refresh_token 刷新
┌────────────┬────────────┬─────────────┬──────────┬────────────┐
│ Session-Id │ 状态 │ 用户 │ Token │ Refresh │
├────────────┼────────────┼─────────────┼──────────┼────────────┤
│ sess-aaa │ authorized │ 张三(user-001)│ tok-bbb★ │ ref-bbb★ │
└────────────┴────────────┴─────────────┴──────────┴────────────┘
↑ 新的 ↑ 新的(轮换)
阶段 4:refresh_token 也过期
┌────────────┬────────────┬─────────────┬──────────┬────────────┐
│ Session-Id │ 状态 │ 用户 │ Token │ Refresh │
├────────────┼────────────┼─────────────┼──────────┼────────────┤
│ sess-aaa │ re-auth │ 张三(user-001)│ (过期) │ (过期) │
└────────────┴────────────┴─────────────┴──────────┴────────────┘
→ 需要用户重新打开浏览器走一遍 OAuth 流程
阶段 5:用户主动撤销
┌────────────┬────────────┬─────────────┬──────────┬────────────┐
│ Session-Id │ 状态 │ 用户 │ Token │ Refresh │
├────────────┼────────────┼─────────────┼──────────┼────────────┤
│ sess-aaa │ revoked │ 张三(user-001)│ (作废) │ (作废) │
└────────────┴────────────┴─────────────┴──────────┴────────────┘
→ 需要重新走一遍完整的连接+授权流程
5. 多 Client 连同一个 Server
5.1 场景
Client A(张三的 ChatBot)──┐ Client B(李四的 ChatBot)──┼──▶ 同一个 MCP Server(localhost:3000) Client C(王五的 ChatBot)──┘
5.2 Server 靠 Mcp-Session-Id 区分
每个 Client 第一次连接时,Server 各自分配不同的 Session-Id:
Client A ──POST /mcp──▶ Server → 分配 sess-aaa-111 Client B ──POST /mcp──▶ Server → 分配 sess-bbb-222 Client C ──POST /mcp──▶ Server → 分配 sess-ccc-333
后续请求各自带自己的 Session-Id:
Client A ──sess-aaa-111──▶ Server → 查表 → 张三的 token Client B ──sess-bbb-222──▶ Server → 查表 → 李四的 token Client C ──sess-ccc-333──▶ Server → 查表 → 王五的 token
5.3 Server 的 Session 表
┌────────────────┬────────────┬──────────────┬───────────────┬──────────────┐ │ Session-Id │ 状态 │ 用户 │ OAuth Token │ Refresh │ ├────────────────┼────────────┼──────────────┼───────────────┼──────────────┤ │ sess-aaa-111 │ authorized │ 张三(user-A) │ tok-aaa │ ref-aaa │ │ sess-bbb-222 │ authorized │ 李四(user-B) │ tok-bbb │ ref-bbb │ │ sess-ccc-333 │ connected │ (无) │ (无) │ (无) │ └────────────────┴────────────┴──────────────┴───────────────┴──────────────┘ 说明: 张三、李四已授权 → 可以正常调工具 王五还未授权 → 调工具会收到 401 王五未授权不影响张三和李四
5.4 各 Client 互不影响
场景:张三的 token 过期了 ═══════════════════════════════════════ → 只影响 sess-aaa-111 → 李四的 sess-bbb-222 不受影响,继续正常工作 → 张三用 refresh_token 刷新后,sess-aaa-111 恢复正常 场景:李四撤销授权 ═══════════════════════════════════════ → 只影响 sess-bbb-222,状态变为 revoked → 张三、王五不受影响 → 李四需要重新走 OAuth 流程才能恢复 场景:王五还没授权 ═══════════════════════════════════════ → sess-ccc-333 状态为 connected → 王五的请求会收到 401 + 授权 URL → 张三、李四正常工作
5.5 各 Client 存储的内容
Client A 存储: session_id = "sess-aaa-111" Client B 存储: session_id = "sess-bbb-222" Client C 存储: session_id = "sess-ccc-333" 每个 Client 只知道自己的 Session-Id 不知道其他 Client 的存在
6. 同一个 Client 连多个 Server
6.1 场景
Client A ──▶ Server 1(文件服务) Session-Id: sess-A1 Client A ──▶ Server 2(邮件服务) Session-Id: sess-A2 Client A ──▶ Server 3(日历服务) Session-Id: sess-A3 Client B ──▶ Server 1(文件服务) Session-Id: sess-B1 Client B ──▶ Server 2(邮件服务) Session-Id: sess-B2
6.2 Client 内部存储
class MCPClient:
def __init__(self):
# 每个 Server 对应一个连接对象
self.connections = {
"文件服务": {
"server_url": "http://file-srv:3001/mcp",
"session_id": "sess-A1" # Server 1 分配的
},
"邮件服务": {
"server_url": "http://mail-srv:3002/mcp",
"session_id": "sess-A2" # Server 2 分配的
},
"日历服务": {
"server_url": "http://cal-srv:3003/mcp",
"session_id": "sess-A3" # Server 3 分配的
}
}
def call_tool(self, server_name, tool, args):
conn = self.connections[server_name]
return http_post(conn["server_url"],
headers={"Mcp-Session-Id": conn["session_id"]},
json={"method": "tools/call", "params": {"name": tool, "arguments": args}}
)
6.3 各 Server 各自的 Session 表
文件服务(Server 1)的 Session 表: ┌────────────────┬────────────┬──────────────┬───────────────┐ │ Session-Id │ 状态 │ 用户 │ Token │ ├────────────────┼────────────┼──────────────┼───────────────┤ │ sess-A1 │ authorized │ 张三 │ tok-文件-A │ │ sess-B1 │ authorized │ 李四 │ tok-文件-B │ └────────────────┴────────────┴──────────────┴───────────────┘ 邮件服务(Server 2)的 Session 表: ┌────────────────┬────────────┬──────────────┬───────────────┐ │ Session-Id │ 状态 │ 用户 │ Token │ ├────────────────┼────────────┼──────────────┼───────────────┤ │ sess-A2 │ authorized │ 张三 │ tok-邮件-A │ │ sess-B2 │ authorized │ 李四 │ tok-邮件-B │ └────────────────┴────────────┴──────────────┴───────────────┘ 日历服务(Server 3)的 Session 表: ┌────────────────┬────────────┬──────────────┬───────────────┐ │ Session-Id │ 状态 │ 用户 │ Token │ ├────────────────┼────────────┼──────────────┼───────────────┤ │ sess-A3 │ connected │ (无) │ (无) │ │ sess-B3 │ authorized │ 李四 │ tok-日历-B │ └────────────────┴────────────┴──────────────┴───────────────┘
6.4 各 Server 之间完全独立
→ 文件服务不知道张三还连着邮件服务 → 邮件服务不知道张三还连着日历服务 → 张三在文件服务的 token 过期,不影响邮件服务的访问 → 张三还没授权日历服务,不影响文件和邮件的使用
6.5 全景图
┌─────────────────────────────────────────────────────────────────┐
│ Client A(张三) │
│ │
│ ┌──────────┬─────────────────┬──────────────────┐ │
│ │ 服务 │ 地址 │ Session-Id │ │
│ ├──────────┼─────────────────┼──────────────────┤ │
│ │ 文件服务 │ file-srv:3001 │ sess-A1 │ │
│ │ 邮件服务 │ mail-srv:3002 │ sess-A2 │ │
│ │ 日历服务 │ cal-srv:3003 │ sess-A3 │ │
│ └──────────┴─────────────────┴──────────────────┘ │
└───────┬──────────────┬──────────────┬───────────────────────────┘
│ │ │
│ sess-A1 │ sess-A2 │ sess-A3
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ 文件服务 │ │ 邮件服务 │ │ 日历服务 │
│ │ │ │ │ │
│ Session 表: │ │ Session 表: │ │ Session 表: │
│ sess-A1 张三 │ │ sess-A2 张三 │ │ sess-A3 未授权 │
│ sess-B1 李四 │ │ sess-B2 李四 │ │ sess-B3 李四 │
│ │ │ │ │ │
│ 不知道其他连接 │ │ 不知道其他连接 │ │ 不知道其他连接 │
└───────────────┘ └───────────────┘ └───────────────┘
7. Token 过期与刷新机制
7.1 两种 Token
| Token | 用途 | 有效期 | 谁持有 |
|---|---|---|---|
access_token |
调 API 用 | 短(1 小时) | MCP Server |
refresh_token |
换新 token 用 | 长(30 天) | MCP Server |
7.2 刷新流程
def call_api_with_auto_refresh(session):
token = session["oauth_token"]
response = http_get(api_url, headers={"Authorization": f"Bearer {token}"})
if response.status == 401:
# access_token 过期 → 用 refresh_token 换新
new_tokens = http_post("https://oauth.server.com/token", data={
"grant_type": "refresh_token",
"refresh_token": session["refresh_token"],
"client_id": "mcp-srv",
"client_secret": "my-secret-key"
})
# 更新 Session(token 轮换:新的 refresh_token 替换旧的)
session["oauth_token"] = new_tokens["access_token"]
session["refresh_token"] = new_tokens["refresh_token"]
# 用新 token 重新请求
response = http_get(api_url,
headers={"Authorization": f"Bearer {new_tokens['access_token']}"}
)
return response
7.3 时间线
00:00 用户授权 → 拿到 access_token (管1小时) + refresh_token (管30天) 00:30 access_token 有效,正常使用 01:00 access_token 过期 → 用 refresh_token 换新 → 新的 access_token 又管1小时 02:00 新的 access_token 又过期 → 再用 refresh_token 换新 ... 30天后 refresh_token 也过期 → 用户需要重新打开浏览器登录
7.4 refresh_token 过期后的 Session 状态
刷新前: ┌────────────┬────────────┬──────┬──────────┬────────────┐ │ Session-Id │ 状态 │ 用户 │ Token │ Refresh │ ├────────────┼────────────┼──────┼──────────┼────────────┤ │ sess-aaa │ authorized │ 张三 │ tok-old │ ref-old │ └────────────┴────────────┴──────┴──────────┴────────────┘ 刷新后: ┌────────────┬────────────┬──────┬──────────┬────────────┐ │ Session-Id │ 状态 │ 用户 │ Token │ Refresh │ ├────────────┼────────────┼──────┼──────────┼────────────┤ │ sess-aaa │ authorized │ 张三 │ tok-new★ │ ref-new★ │ └────────────┴────────────┴──────┴──────────┴────────────┘ refresh_token 也过期: ┌────────────┬────────────┬──────┬──────────┬────────────┐ │ Session-Id │ 状态 │ 用户 │ Token │ Refresh │ ├────────────┼────────────┼──────┼──────────┼────────────┤ │ sess-aaa │ re-auth │ 张三 │ (过期) │ (过期) │ └────────────┴────────────┴──────┴──────────┴────────────┘ → Client 收到 401 → 重新打开浏览器走 OAuth 流程
8. 安全防护机制
8.1 为什么拦截 callback 没用
攻击者拦截 callback → 拿到 code 但 code 换 token 需要 client_secret(只有 Server 知道) → 没有 client_secret → 换不了 token 额外防线: ✓ code 一次性(用过作废) ✓ code 短有效期(5-10 分钟) ✓ redirect_uri 二次验证(换 token 时验证是否一致) ✓ state 参数绑定 Session(防止 CSRF)
8.2 redirect_uri 白名单
部署时注册:
OAuth Server 后台 → 设置回调白名单
✓ http://mcp-server.com/callback
✓ http://localhost:3000/callback
运行时:
Client 构造的 redirect_uri 必须在白名单里
否则 OAuth Server 直接拒绝
8.3 state 防 CSRF
Client 生成随机 state = "sess-aaa-111" → 发给 OAuth Server → OAuth Server 原样带回 → Client 验证 callback 里的 state 和自己发出去的是否一致 → 不一致 → 拒绝(可能是攻击者伪造的回调)
感谢:LongCat-AI友情赞助
附录:关键问题速查
Q1:Session-Id 什么时候产生?
Client 第一次 initialize 握手时就分配了,不需要等认证。一开始是"空壳 Session",OAuth 完成后才被填入用户身份和 token。
Q2:Session-Id 放在哪?
Session 表只在 Server 端。Client 只存自己的 Session-Id 一个字符串。
Q3:多个 Client 连同一个 Server 怎么区分?
Server 给每个 Client 分配不同的 Session-Id,靠 Mcp-Session-Id Header 区分。
Q4:一个 Client 连多个 Server 怎么管理?
Client 按服务名分开存:connections["文件服务"].session_id = "sess-A1",各 Server 各自管自己的 Session 表。
Q5:Client 怎么知道授权成功了?
轮询:打开浏览器后每隔几秒用同一个 Session-Id 试一次,试到 200 就说明授权成功了。
Q6:token 过期了怎么办?
Server 用 refresh_token 自动换新,用户无感知。refresh_token 也过期了才需要用户重新登录。
Q7:为什么不直接给 Client token?
Client 不可信。token 给 Server 才能让 Server 控制权限范围,即使 Client 被攻破,真 token 也不会泄露。
Q8:拦截 callback 地址能盗取 token 吗?

浙公网安备 33010602011771号