给 MCP Server 加 OAuth 认证并部署上线
上一篇我们把一个接口包装成了 MCP Server,但它有个致命问题——裸奔在公网,谁都能连。这一篇补上最后一块拼图:用 OAuth 给 Server 加上认证门禁,再用 Docker 部署到自己的服务器上,做到真正生产可用。读完你会有一套可直接套用的方案。
📌 这是 MCP 系列第二篇。第一篇《手把手教你把现有 API 包装成 MCP Server》如果没看,建议先读,本文会直接复用那个
user-service的例子。
一、为什么 MCP Server 需要 OAuth
上一篇结尾我们把 Server 用 HTTP 方式部署后,配置长这样:
{
"mcpServers": {
"user-service": {
"url": "http://your-server.com:8000/mcp"
}
}
}
问题很明显:这个 URL 谁拿到谁就能调你的工具,查用户、查订单全部门户大开。内网还好,一旦放到公网就是事故现场。
你可能想到「加个固定 Token 不就行了?」——可以,但有几个硬伤:
- Token 写死,泄露了无法精细回收
- 没有用户身份,分不清是谁在调、调了什么
- 没有过期、刷新、权限范围(scope)的概念
OAuth 2.1 正是为解决这些而生。好消息是,MCP 规范在 2025 年的版本里已经正式把 Authorization 纳入标准,定义了 MCP Server 作为 OAuth Resource Server 该怎么接入。我们不用自己发明轮子,照规范接现成的认证服务即可。
二、先理清角色:MCP 的 OAuth 模型
OAuth 涉及好几个角色,名词一多就晕。先用一张表对应到我们的场景:
| OAuth 角色 | 职责 | 在我们这里是谁 |
|---|---|---|
| Resource Server | 持有受保护资源,校验 token | 你的 MCP Server |
| Authorization Server | 负责登录、发 token | Keycloak / Auth0 等认证服务 |
| Client | 想访问资源的一方 | Cursor / Claude Desktop 等 |
| Resource Owner | 资源的主人 | 用户(你或团队成员) |
整体流程(简化版):
1. 客户端想连 MCP Server
2. Server 说:"先去认证服务拿 token"
3. 客户端引导用户登录认证服务,拿到 access_token
4. 客户端带着 token 再来连 Server
5. Server 校验 token 通过 → 放行调用工具
关键认知:登录、发 token 这些脏活累活,全交给专业的认证服务(Authorization Server)去做,你的 MCP Server 只负责"校验 token 真不真、够不够权限" 。这就是我们选「接入现成认证服务」而不是手写一套的原因——OAuth 自己实现极易出安全漏洞。
三、认证服务选型
主流选择对比:
| 方案 | 类型 | 适合场景 |
|---|---|---|
| Keycloak | 开源自托管 | 想数据自主可控、不想付费,本文主讲 |
| Auth0 | 商业 SaaS | 想省事、不想运维,有免费额度 |
| Okta | 商业 SaaS | 企业级,已在用 Okta 的团队 |
| Authentik / Logto | 开源自托管 | Keycloak 的轻量替代 |
本文以 Keycloak 为主线(自托管、免费、和上一篇 Docker 部署一脉相承),文末给出 Auth0 的等价配置思路。
四、用 Docker 跑起一个 Keycloak
先把认证服务搭起来。开发环境用一行命令即可:
docker run -d \
--name keycloak \
-p 8080:8080 \
-e KEYCLOAK_ADMIN=admin \
-e KEYCLOAK_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:26.0 \
start-dev
⚠️
start-dev和admin/admin仅用于开发。生产环境要用start、配数据库、改强密码、上 HTTPS,后面会讲。
访问 http://localhost:8080,用 admin/admin 登录管理后台,然后做三件事:
1. 创建一个 Realm(领域)
Realm 是 Keycloak 里隔离的租户单元。左上角下拉 → Create Realm → 命名为 mcp。
2. 创建一个 Client(客户端)
代表来连接的应用(Cursor 等):
- Clients → Create client
- Client ID 填
mcp-server - Client authentication 视情况开启
- 在 Valid redirect URIs 里填客户端的回调地址
3. 创建测试用户
Users → Add user,建一个账号并在 Credentials 里设密码,用于后面登录测试。
这样你的 Authorization Server 就就绪了。它会暴露一组标准端点,关键的是这个「发现地址」:
http://localhost:8080/realms/mcp/.well-known/openid-configuration
里面包含了 token 校验需要的所有信息(公钥地址 jwks_uri、issuer 等)。
五、给 MCP Server 接上 token 校验
现在改造上一篇的 server.py,让它在放行工具调用前先校验 token。
核心逻辑:收到请求 → 取出 Authorization 头里的 Bearer token → 用 Keycloak 的公钥验证签名和有效期 → 通过才执行工具。
先装依赖(在上一篇 requirements.txt 基础上加):
mcp[cli]>=1.2.0
httpx>=0.27.0
python-dotenv>=1.0.0
pyjwt[crypto]>=2.8.0
.env 增加认证相关配置:
API_BASE_URL=https://api.mycompany.com
API_KEY=your-secret-key-here
OAUTH_ISSUER=http://localhost:8080/realms/mcp
OAUTH_JWKS_URL=http://localhost:8080/realms/mcp/protocol/openid-connect/certs
OAUTH_AUDIENCE=mcp-server
新建一个校验模块 auth.py:
import os
import jwt
from jwt import PyJWKClient
ISSUER = os.getenv("OAUTH_ISSUER")
JWKS_URL = os.getenv("OAUTH_JWKS_URL")
AUDIENCE = os.getenv("OAUTH_AUDIENCE")
# PyJWKClient 会自动拉取并缓存 Keycloak 的公钥
_jwks_client = PyJWKClient(JWKS_URL)
def verify_token(token: str) -> dict:
"""校验 access_token,通过返回 payload,失败抛异常。"""
signing_key = _jwks_client.get_signing_key_from_jwt(token)
payload = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
audience=AUDIENCE,
issuer=ISSUER,
)
return payload # 里面有 sub(用户ID)、scope、exp 等
这段代码做了 OAuth 校验该做的所有核心检查:签名是否由可信认证服务签发、是否过期、issuer 和 audience 是否匹配。任何一项不过就抛异常。
把校验挂到 HTTP 入口
MCP 用 HTTP 传输时,底层是一个 ASGI 应用。我们用中间件统一拦截,在请求进入工具前完成校验:
# server.py
import os
import httpx
from dotenv import load_dotenv
from mcp.server.fastmcp import FastMCP
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from auth import verify_token
load_dotenv()
mcp = FastMCP("user-service")
API_BASE = os.getenv("API_BASE_URL")
API_KEY = os.getenv("API_KEY")
client = httpx.Client(
base_url=API_BASE,
headers={"Authorization": f"Bearer {API_KEY}"},
timeout=10.0,
)
@mcp.tool()
def get_user_by_id(user_id: str) -> dict:
"""根据用户 ID 查询用户的详细信息,包括姓名、邮箱、手机号、注册时间和状态。
Args:
user_id: 用户的唯一标识,例如 "U10086"
"""
try:
resp = client.get(f"/api/users/{user_id}")
if resp.status_code == 404:
return {"error": f"用户 {user_id} 不存在"}
resp.raise_for_status()
data = resp.json()
return {
"id": data.get("id"),
"name": data.get("name"),
"email": data.get("email"),
"status": data.get("status"),
"created_at": data.get("created_at"),
}
except Exception as e:
return {"error": f"查询失败: {str(e)}"}
# ---- OAuth 校验中间件 ----
class AuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
# 放行 OAuth 元数据等公开端点
if request.url.path.startswith("/.well-known"):
return await call_next(request)
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return JSONResponse(
{"error": "missing bearer token"},
status_code=401,
headers={"WWW-Authenticate": 'Bearer'},
)
token = auth_header.removeprefix("Bearer ").strip()
try:
payload = verify_token(token)
request.state.user = payload # 后续可取用户信息
except Exception as e:
return JSONResponse(
{"error": f"invalid token: {e}"},
status_code=401,
)
return await call_next(request)
# 取出底层 ASGI app 并挂上中间件
app = mcp.streamable_http_app()
app.add_middleware(AuthMiddleware)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
💡 这里手写中间件是为了讲清原理。新版 MCP SDK 已内置了对 OAuth 的支持(
auth相关参数),生产中可优先用官方封装,原理与上面完全一致。
按 scope 做精细权限(可选)
token 的 payload 里有 scope 字段。如果想让某些工具只对特定权限开放,在工具内部判断即可:
# 在中间件里把 payload 存到 request.state.user 后,
# 工具内可校验:
scopes = request.state.user.get("scope", "").split()
if "user:read" not in scopes:
return {"error": "权限不足"}
六、本地联调
启动两个服务(Keycloak 已在跑):
python server.py
先模拟客户端拿 token——用密码模式向 Keycloak 换取(仅测试用):
curl -X POST \
http://localhost:8080/realms/mcp/protocol/openid-connect/token \
-d "grant_type=password" \
-d "client_id=mcp-server" \
-d "username=testuser" \
-d "password=你的密码"
# 返回 JSON 里有 access_token
带着 token 调 MCP Server:
# 不带 token:401
curl http://localhost:8000/mcp
# {"error": "missing bearer token"}
# 带上 token:放行
curl http://localhost:8000/mcp \
-H "Authorization: Bearer <上一步的access_token>"
能看到带 token 才通过,认证就生效了。
七、部署上线(Docker + VPS)
承接上一篇的 Docker 思路,把 MCP Server 和 Keycloak 一起用 docker-compose 编排,再用 Nginx 上 HTTPS。
docker-compose.yml
services:
keycloak:
image: quay.io/keycloak/keycloak:26.0
command: start
environment:
KC_HOSTNAME: auth.yourdomain.com
KC_PROXY_HEADERS: xforwarded
KC_HTTP_ENABLED: "true"
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://db:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: ${DB_PASSWORD}
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD}
depends_on: [db]
db:
image: postgres:16
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
mcp-server:
build: .
environment:
API_BASE_URL: https://api.mycompany.com
API_KEY: ${API_KEY}
OAUTH_ISSUER: https://auth.yourdomain.com/realms/mcp
OAUTH_JWKS_URL: https://auth.yourdomain.com/realms/mcp/protocol/openid-connect/certs
OAUTH_AUDIENCE: mcp-server
depends_on: [keycloak]
volumes:
pgdata:
Dockerfile 在上一篇基础上把启动命令改成 uvicorn 即可(已在 server.py 里用 uvicorn 启动,原 Dockerfile 可直接用)。
Nginx 反向代理 + HTTPS(关键片段):
server {
listen 443 ssl;
server_name mcp.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/mcp.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mcp.yourdomain.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
# MCP 的 SSE 长连接需要关闭缓冲
proxy_buffering off;
proxy_read_timeout 3600s;
}
}
证书用 Let's Encrypt 免费签发:
certbot --nginx -d mcp.yourdomain.com -d auth.yourdomain.com
上线检查清单
| 项 | 要求 |
|---|---|
| HTTPS | MCP Server 和 Keycloak 都必须走 HTTPS |
| Keycloak 启动模式 | 用 start 而非 start-dev |
| 数据库 | Keycloak 配 PostgreSQL,别用内置内存库 |
| 管理员密码 | 强密码,从环境变量注入,别进 git |
| token 有效期 | 在 Realm 设置里设合理的过期时间 |
| SSE 长连接 | Nginx 关 proxy_buffering、调大 timeout |
客户端最终接入配置:
{
"mcpServers": {
"user-service": {
"url": "https://mcp.yourdomain.com/mcp"
}
}
}
支持 OAuth 的客户端首次连接会自动跳转登录、完成授权,后续自动带 token。
八、用 Auth0 的等价做法(SaaS 路线)
不想自己运维 Keycloak?换成 Auth0 几乎只改配置,代码里的校验逻辑一字不动:
-
在 Auth0 建一个 API(对应 audience),建一个 Application(对应 client)
-
把
.env里三个值换成 Auth0 的:OAUTH_ISSUER→https://你的租户.auth0.com/OAUTH_JWKS_URL→https://你的租户.auth0.com/.well-known/jwks.jsonOAUTH_AUDIENCE→ 你在 Auth0 建的 API Identifier
-
auth.py里verify_token完全复用
这就是接标准 OAuth 的好处——换认证服务商,业务代码零改动。
九、番外:Server 去调第三方 OAuth(场景 B)
前面讲的是「保护自己的 Server」。还有一种场景:你的工具内部要调 GitHub、Google 这类需要用户授权的 API,Server 得替用户去走它们的 OAuth。
思路与上面相反——这次你的 Server 是 OAuth 的 Client:
- 工具被调用时发现没有第三方 token
- 返回一个授权链接,引导用户去 GitHub 授权
- 用户授权后,回调把 code 换成 access_token,存起来
- 后续工具调用带上这个 token 去访问 GitHub API
实现上通常用 authlib 这类库管理授权码流程,把拿到的 token 按用户存储(加密)。这块展开够单独一篇,这里先建立认知:保护自己 ≠ 访问第三方,两者 OAuth 角色正好相反。
十、结语
到这里,你的 MCP Server 已经从「裸奔」进化到「生产可用」:
- ✅ 用标准 OAuth 2.1 做认证,告别固定的 Token
- ✅ 认证交给专业服务(Keycloak / Auth0),自己只管校验
- ✅ Docker Compose 编排,Nginx + HTTPS 部署上线
- ✅ 换认证服务商业务代码零改动
配合上一篇,你已经走完「写工具 → 加认证 → 部署上线」的完整闭环。MCP 正在成为 AI 接入外部能力的事实标准,把内部接口安全地开放给 AI 用,是接下来很值钱的一项工程能力。
如果这篇帮到你,欢迎点赞 + 收藏。