上一篇我们把一个接口包装成了 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负责登录、发 tokenKeycloak / 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-devadmin/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

上线检查清单

要求
HTTPSMCP 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_ISSUERhttps://你的租户.auth0.com/
    • OAUTH_JWKS_URLhttps://你的租户.auth0.com/.well-known/jwks.json
    • OAUTH_AUDIENCE → 你在 Auth0 建的 API Identifier
  • auth.pyverify_token 完全复用

这就是接标准 OAuth 的好处——换认证服务商,业务代码零改动

九、番外:Server 去调第三方 OAuth(场景 B)

前面讲的是「保护自己的 Server」。还有一种场景:你的工具内部要调 GitHub、Google 这类需要用户授权的 API,Server 得替用户去走它们的 OAuth。

思路与上面相反——这次你的 Server 是 OAuth 的 Client

  1. 工具被调用时发现没有第三方 token
  2. 返回一个授权链接,引导用户去 GitHub 授权
  3. 用户授权后,回调把 code 换成 access_token,存起来
  4. 后续工具调用带上这个 token 去访问 GitHub API

实现上通常用 authlib 这类库管理授权码流程,把拿到的 token 按用户存储(加密)。这块展开够单独一篇,这里先建立认知:保护自己 ≠ 访问第三方,两者 OAuth 角色正好相反

十、结语

到这里,你的 MCP Server 已经从「裸奔」进化到「生产可用」:

  • ✅ 用标准 OAuth 2.1 做认证,告别固定的 Token
  • ✅ 认证交给专业服务(Keycloak / Auth0),自己只管校验
  • ✅ Docker Compose 编排,Nginx + HTTPS 部署上线
  • ✅ 换认证服务商业务代码零改动

配合上一篇,你已经走完「写工具 → 加认证 → 部署上线」的完整闭环。MCP 正在成为 AI 接入外部能力的事实标准,把内部接口安全地开放给 AI 用,是接下来很值钱的一项工程能力。


如果这篇帮到你,欢迎点赞 + 收藏