上一篇使用本地脚本签发 JWT,已经可以验证身份并限制 Tool 权限。但是客户端仍然需要提前拿到 Token,整个过程没有用户登录、授权确认、授权码回调和 Token 刷新。
这一篇继续实现完整的浏览器授权流程:
FastMCP Client
→ MCP OAuth 发现
→ OAuthProxy 授权确认
→ Keycloak 用户登录
→ Keycloak Scope 同意
→ Authorization Code + PKCE
→ OAuthProxy Token Factory
→ MCP Access Token
→ 受保护的 MCP Tool
协议行为以 MCP 2025-11-25 的授权规范为基线。本机开发使用 HTTP,生产环境必须改成 HTTPS。
1. OAuth 2.1 中有哪些角色
本文涉及四个主要角色:
- Resource Owner:真正进行登录和授权确认的用户。
- MCP Client:需要调用 Tool 的 FastMCP 或 LangChain 客户端。
- Authorization Server:从 MCP Client 的视角看,负责发现、客户端注册、授权码和 Token 的 OAuthProxy。
- Resource Server:验证 MCP Access Token 并提供 Tool 的 FastMCP Server。
Keycloak 是 OAuthProxy 的上游身份提供方和授权服务,负责用户登录、会话与 Scope 同意。OAuthProxy 同时承担面向 MCP Client 的 Authorization Server 和 Token Factory。区分这两个视角,才不会把上游 Keycloak Token 与下游 MCP Token 混为一个凭据。

这里不能把 Keycloak 与 OAuthProxy 简单理解成两个重复的认证服务。它们保护的是不同边界:
- Keycloak Token 面向固定的上游客户端 mcp-oauth-proxy。
- OAuthProxy 签发的 Token 面向 MCP Resource http://127.0.0.1:18130/mcp。
2. MCP Client 如何发现授权服务
客户端第一次请求 /mcp 时没有 Token,Resource Server 返回 401 和 WWW-Authenticate。客户端再读取 Protected Resource Metadata:
/.well-known/oauth-protected-resource/mcp
这里会告诉客户端:
- 当前 Resource 是谁。
- 可以去哪些 Authorization Server 授权。
- Resource 支持哪些 Scope。
然后客户端读取 Authorization Server Metadata:
/.well-known/oauth-authorization-server
从中发现授权端点、Token 端点、客户端注册端点和 PKCE 能力。

这种发现机制让客户端不需要在代码中手工写死全部 OAuth 端点。流程细节可对照 MCP 2025-11-25 Authorization。
3. Authorization Code 与 PKCE S256
浏览器授权使用 Authorization Code 流程。客户端不会直接在浏览器中取得 Access Token,而是先获得一次性授权码,再通过后端 Token 端点交换 Token。
PKCE 在授权前生成一段随机 code_verifier,并把它转换成 code_challenge:
code_challenge = BASE64URL(SHA256(code_verifier))
授权请求只发送 code_challenge,Token 请求再发送原始 code_verifier。即使授权码被截获,没有 code_verifier 也不能完成交换。
本例只接受 S256,不使用安全性更弱的 plain。
state 用于把回调与最初的授权请求对应起来,防止登录 CSRF。回调地址也必须与客户端注册信息严格匹配。
4. 为什么增加 OAuthProxy
Keycloak 26.6.2 可以提供标准 OIDC 和 OAuth 服务,但它不接收 RFC 8707 定义的 resource 参数。MCP 授权规范要求客户端在授权请求和 Token 请求中携带 Resource Indicator,让 Token 明确绑定目标资源。
本例使用 OAuthProxy 解决这一边界:
forward_resource=False
resource_base_url="http://127.0.0.1:18130"
forward_resource=False 表示不把 Keycloak 不支持的参数传给上游。OAuthProxy 收到 Keycloak Token 后,不会把它原样返回给 MCP Client,而是重新签发面向 MCP Resource 的 Token。

这同时满足一个重要安全原则:MCP Client Token 不能被原样转发给下游服务,上游身份 Token 也不能暴露给 MCP Tool。
5. 项目结构与端口
代码位于:
mcp/p07_keycloak_oauth_agent/
├── 01_prepare_local_config.py
├── 02_check_authorization_metadata.py
├── 03_oauth_client_login.py
├── 04_test_oauth_scopes.py
├── 05_agent_with_oauth_mcp.py
├── oauth_proxy_server.py
├── docker-compose.yml
├── .env.example
├── requirements.in
├── requirements-lock.txt
└── README.md
使用的本机端口如下:
| 端口 | 服务 |
|---|---|
| 18080 | Qwen3 OpenAI 兼容接口 |
| 18130 | OAuthProxy 与 MCP Resource Server |
| 18131 | Keycloak |
| 18132 | FastMCP Client 临时回调服务 |
安装依赖:
cd source/_posts/llm_learning
source .venv_mcp_auth/bin/activate
python -m pip install -r mcp/p07_keycloak_oauth_agent/requirements-lock.txt
python -m pip check
主要版本:
fastmcp==3.3.1
mcp==1.27.1
langchain-mcp-adapters==0.2.2
keycloak==26.6.2
这里的 keycloak==26.6.2 表示 Docker 镜像版本,不是 Python 包。
6. 生成本地配置
01_prepare_local_config.py 会生成:
- Keycloak 管理员随机密码。
- 测试用户随机密码。
- Keycloak 上游客户端 Secret。
- OAuthProxy RSA 签名密钥。
- OAuth Client 本地缓存加密密钥。
- Realm 导入文件。
运行:
python mcp/p07_keycloak_oauth_agent/01_prepare_local_config.py
输出只包含稳定信息:
本地 OAuth 配置已生成到 .secrets,未输出密码和签名密钥。
Keycloak Realm: mcp-demo
测试用户: mcp-user
登录文件使用 MCP_TEST_USERNAME 和 MCP_TEST_PASSWORD 两个专用变量名。不要使用 zsh 中已有特殊含义的 USERNAME,否则脚本可能读取到 macOS 当前登录用户,而不是 Keycloak 测试用户。
敏感文件保存在:
mcp/p07_keycloak_oauth_agent/.secrets/
生成脚本会把 .secrets 和 Token 缓存目录设为 0700,把密码、密钥与 Realm 导入文件设为 0600。仅加密文件内容还不够,目录权限也要阻止其他本地用户枚举文件名。
不增加 –force 时,脚本不会覆盖已有密钥。这样可以避免 OAuthProxy 重启后突然更换签名密钥。
使用 –force 重新生成配置时,加密密钥也会变化,因此脚本会同步清空旧 Token 缓存,避免新密钥读取旧密文时失败。
Realm 配置中启用了 Authorization Code,关闭 Direct Access Grant 和 Service Account,并强制上游客户端使用 PKCE S256:
"standardFlowEnabled": True,
"directAccessGrantsEnabled": False,
"serviceAccountsEnabled": False,
"consentRequired": True,
"redirectUris": ["http://127.0.0.1:18130/auth/callback"],
"attributes": {
"pkce.code.challenge.method": "S256",
},
"optionalClientScopes": ["profile:read", "profile:write"],
"protocolMappers": [
{
"name": "mcp-preferred-username",
"protocolMapper": "oidc-usermodel-property-mapper",
"config": {
"user.attribute": "username",
"claim.name": "preferred_username",
"access.token.claim": "true",
},
},
# 另一个 Mapper 把 mcp-oauth-proxy 写入 Access Token 的 aud。
],
preferred_username Mapper 让服务端能够在验证 Token 后执行资料归属检查;Audience Mapper 则保证上游 Token 只能交给 mcp-oauth-proxy 使用。两者用途不同,不能相互替代。
7. 启动 Keycloak 26.6.2
docker-compose.yml:
services:
keycloak:
image: quay.io/keycloak/keycloak:26.6.2
container_name: llm-learning-p07-keycloak
command: start-dev --import-realm
env_file:
- .secrets/keycloak.env
ports:
- "127.0.0.1:18131:8080"
volumes:
- p07_keycloak_data:/opt/keycloak/data
- ./.secrets/realm-import.json:/opt/keycloak/data/import/realm-import.json:ro
healthcheck:
test:
- CMD-SHELL
- >-
exec 3<>/dev/tcp/127.0.0.1/9000 &&
printf 'GET /health/ready HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' >&3 &&
grep -q '200 OK' <&3
interval: 5s
timeout: 3s
retries: 30
volumes:
p07_keycloak_data:
启动:
docker compose \
-f mcp/p07_keycloak_oauth_agent/docker-compose.yml \
up -d
确认健康状态后,可以访问 Realm 的 OIDC 元数据:
curl --noproxy '*' \
http://127.0.0.1:18131/realms/mcp-demo/.well-known/openid-configuration
实测 issuer 为:
http://127.0.0.1:18131/realms/mcp-demo
并且 code_challenge_methods_supported 包含 S256。
8. 编写 OAuthProxy MCP Server
oauth_proxy_server.py 的核心代码如下:
"""使用 Keycloak 处理登录,并由 OAuthProxy 签发面向 MCP 的访问令牌。"""
import os
from pathlib import Path
from dotenv import load_dotenv
from fastmcp import FastMCP
from fastmcp.server.auth import OAuthProxy, require_scopes
from fastmcp.server.auth.providers.jwt import JWTVerifier
from fastmcp.server.dependencies import get_access_token
BASE_DIR = Path(__file__).resolve().parent
load_dotenv(BASE_DIR / ".secrets" / "oauth.env")
KEYCLOAK_BASE_URL = os.environ["KEYCLOAK_BASE_URL"]
KEYCLOAK_REALM = os.environ["KEYCLOAK_REALM"]
KEYCLOAK_ISSUER = f"{KEYCLOAK_BASE_URL}/realms/{KEYCLOAK_REALM}"
MCP_BASE_URL = os.environ["MCP_BASE_URL"]
# Keycloak Token 只在 OAuthProxy 内部验证和保存,不直接交给 MCP Tool。
upstream_verifier = JWTVerifier(
jwks_uri=f"{KEYCLOAK_ISSUER}/protocol/openid-connect/certs",
issuer=KEYCLOAK_ISSUER,
audience=os.environ["KEYCLOAK_CLIENT_ID"],
algorithm="RS256",
required_scopes=["profile:read"],
)
auth = OAuthProxy(
upstream_authorization_endpoint=(
f"{KEYCLOAK_ISSUER}/protocol/openid-connect/auth"
),
upstream_token_endpoint=f"{KEYCLOAK_ISSUER}/protocol/openid-connect/token",
upstream_revocation_endpoint=(
f"{KEYCLOAK_ISSUER}/protocol/openid-connect/revoke"
),
upstream_client_id=os.environ["KEYCLOAK_CLIENT_ID"],
upstream_client_secret=os.environ["KEYCLOAK_CLIENT_SECRET"],
token_verifier=upstream_verifier,
base_url=MCP_BASE_URL,
resource_base_url=MCP_BASE_URL,
valid_scopes=["openid", "profile:read", "profile:write"],
forward_pkce=True,
# Keycloak 26.6.2 不支持 RFC 8707 Resource Indicators,因此不向上游转发。
forward_resource=False,
jwt_signing_key=os.environ["OAUTH_PROXY_SIGNING_KEY"],
require_authorization_consent=True,
allowed_client_redirect_uris=[
"http://127.0.0.1:*/*",
"http://localhost:*/*",
],
)
mcp = FastMCP(
name="keycloak-oauth-profile-mcp",
instructions="提供经过 OAuth 2.1 登录和 Scope 授权的用户资料工具。",
auth=auth,
)
USER_PROFILES = {
"user-001": {"name": "小明", "city": "杭州", "note": "喜欢周末去西湖散步"},
"user-002": {"name": "小红", "city": "上海", "note": "喜欢阅读"},
}
AUTHORIZED_PROFILES = {
"mcp-user": "user-001",
}
def can_access_profile(user_id: str) -> bool:
"""根据已验证的 Keycloak 身份执行对象级授权。"""
token = get_access_token()
username = token.claims.get("preferred_username")
return AUTHORIZED_PROFILES.get(username) == user_id
@mcp.tool(auth=require_scopes("profile:read"))
def get_user_profile(user_id: str) -> dict:
"""读取固定用户资料,需要 profile:read 权限。"""
if not can_access_profile(user_id):
return {"error": "forbidden", "message": "当前身份无权读取该用户资料"}
profile = USER_PROFILES.get(user_id)
if profile is None:
return {"error": "用户不存在"}
return {
"authenticated": True,
"profile": profile,
}
@mcp.tool(auth=require_scopes("profile:write"))
def update_user_note(user_id: str, note: str) -> dict:
"""更新固定用户的备注,需要 profile:write 权限。"""
if not can_access_profile(user_id):
return {"error": "forbidden", "message": "当前身份无权修改该用户资料"}
profile = USER_PROFILES.get(user_id)
if profile is None:
return {"error": "用户不存在"}
profile["note"] = note
return {
"authenticated": True,
"user_id": user_id,
"note": note,
}
if __name__ == "__main__":
mcp.run(
transport="http",
host="127.0.0.1",
port=18130,
path="/mcp",
show_banner=False,
)
启动:
python mcp/p07_keycloak_oauth_agent/oauth_proxy_server.py
这里没有为了提取用户名而“关闭签名验证后解码 JWT”。get_access_token() 读取的 claims 来自 JWTVerifier 已经验证过的 Keycloak Access Token。这些 claims 只在服务端用于对象级授权,不会传入 Prompt。
本机 HTTP 会出现非安全 Cookie 警告,这是开发环境的预期提示。生产环境必须使用 HTTPS,并设置严格的回调地址白名单。
9. 验证 OAuth 元数据
02_check_authorization_metadata.py 只打印稳定字段,不输出 Session ID、请求 ID 或 Token。
"""检查 MCP 401 Challenge、Protected Resource Metadata 和授权服务器元数据。"""
import json
import httpx
MCP_URL = "http://127.0.0.1:18130/mcp"
RESOURCE_METADATA_URL = (
"http://127.0.0.1:18130/.well-known/oauth-protected-resource/mcp"
)
AUTHORIZATION_METADATA_URL = (
"http://127.0.0.1:18130/.well-known/oauth-authorization-server"
)
def print_selected(name: str, data: dict, keys: list[str]) -> None:
"""只打印与协议发现有关的稳定字段。"""
selected = {key: data.get(key) for key in keys}
print(name + ":", json.dumps(selected, ensure_ascii=False))
with httpx.Client(trust_env=False, timeout=10) as client:
unauthorized = client.get(MCP_URL)
print("mcp_status:", unauthorized.status_code)
print("has_bearer_challenge:", "Bearer" in unauthorized.headers.get("www-authenticate", ""))
resource_metadata = client.get(RESOURCE_METADATA_URL)
print("resource_metadata_status:", resource_metadata.status_code)
print_selected(
"resource_metadata",
resource_metadata.json(),
["resource", "authorization_servers", "scopes_supported"],
)
authorization_metadata = client.get(AUTHORIZATION_METADATA_URL)
print("authorization_metadata_status:", authorization_metadata.status_code)
print_selected(
"authorization_metadata",
authorization_metadata.json(),
[
"issuer",
"authorization_endpoint",
"token_endpoint",
"registration_endpoint",
"code_challenge_methods_supported",
],
)
真实结果:
mcp_status: 401
has_bearer_challenge: True
resource_metadata_status: 200
authorization_metadata_status: 200
authorization_metadata: {"issuer":"http://127.0.0.1:18130/","authorization_endpoint":"http://127.0.0.1:18130/authorize","token_endpoint":"http://127.0.0.1:18130/token","registration_endpoint":"http://127.0.0.1:18130/register","code_challenge_methods_supported":["S256"]}
registration_endpoint 表示当前 OAuthProxy 支持 Dynamic Client Registration。规范还允许预注册和 Client ID Metadata Document,不能把 DCR 视为唯一方式。
10. 真实完成浏览器登录
03_oauth_client_login.py 使用 FastMCP OAuth。首次运行时,它会自动完成元数据发现、DCR、PKCE 和本地回调服务。
"""使用 FastMCP OAuth Client 完成浏览器登录并调用只读 Tool。"""
import asyncio
import os
from pathlib import Path
from cryptography.fernet import Fernet
from dotenv import load_dotenv
from fastmcp import Client
from fastmcp.client.auth import OAuth
from key_value.aio.stores.disk import DiskStore
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
BASE_DIR = Path(__file__).resolve().parent
load_dotenv(BASE_DIR / ".secrets" / "oauth.env")
def create_oauth() -> OAuth:
"""创建带加密磁盘缓存的 OAuth 客户端认证对象。"""
disk_store = DiskStore(directory=BASE_DIR / ".secrets" / "oauth-client-cache")
encrypted_store = FernetEncryptionWrapper(
key_value=disk_store,
fernet=Fernet(os.environ["OAUTH_STORAGE_ENCRYPTION_KEY"].encode("ascii")),
)
return OAuth(
mcp_url="http://127.0.0.1:18130/mcp",
scopes=["profile:read"],
callback_port=18132,
token_storage=encrypted_store,
client_name="llm-learning-oauth-client",
)
async def main() -> None:
"""首次运行打开浏览器,登录后列出并调用授权范围内的 Tool。"""
async with Client(
"http://127.0.0.1:18130/mcp",
auth=create_oauth(),
timeout=180,
) as client:
tools = await client.list_tools()
print("authorized_tools:", [tool.name for tool in tools])
result = await client.call_tool("get_user_profile", {"user_id": "user-001"})
print("read_result:", result.content[0].text)
# Scope 允许读取资料,但服务端仍需检查资料是否属于当前用户。
denied_result = await client.call_tool(
"get_user_profile", {"user_id": "user-002"}
)
print("other_user_result:", denied_result.content[0].text)
if __name__ == "__main__":
asyncio.run(main())
运行:
python mcp/p07_keycloak_oauth_agent/03_oauth_client_login.py
浏览器先显示 OAuthProxy 的客户端授权确认页:
确认回调地址属于当前客户端后,进入 Keycloak 登录页:

登录后,Keycloak 显示上游客户端请求的 Scope:
授权完成后,浏览器回到 http://localhost:18132/callback,FastMCP Client 自动交换 Token 并建立 MCP Session。
首次授权的真实 Tool 调用结果:
authorized_tools: ['get_user_profile', 'update_user_note']
read_result: {"authenticated":true,"profile":{"name":"小明","city":"杭州","note":"喜欢周末去西湖散步"}}
other_user_result: {"error":"forbidden","message":"当前身份无权读取该用户资料"}
客户端缓存经过 Fernet 加密,并保存在 Git 忽略目录。不要使用明文 JSON 保存 Access Token 和 Refresh Token。
这里虽然在 OAuth(scopes=…) 中写了 profile:read,首次发现仍申请了 Resource Metadata 公布的全部 Scope。这是 MCP Client 的 Scope 选择策略:如果 WWW-Authenticate 没有指定 Scope,则优先使用 Protected Resource Metadata 中的 scopes_supported。因此“构造函数里写了只读”不能代替服务端最小权限设计。下一节会把已经取得的授权降为只读。
11. 使用 Refresh Token 降低权限
初次发现时,MCP Client 会按资源元数据选择 Scope。需要缩小权限时,可以在 Refresh Token 请求中只申请原授权的子集。
04_test_oauth_scopes.py 请求:
response = await http_client.post(
"http://127.0.0.1:18130/token",
data={
"grant_type": "refresh_token",
"refresh_token": tokens.refresh_token,
"client_id": client_info.client_id,
"scope": "profile:read",
"resource": "http://127.0.0.1:18130/mcp",
},
)
# Refresh Token 可能被轮换,必须把完整响应写回加密缓存。
read_only_tokens = OAuthToken.model_validate(response.json())
await storage.set_tokens(read_only_tokens)
这里再次携带 resource,避免 Token 被交换给错误的目标服务。
只保存新的 Access Token 而丢掉轮换后的 Refresh Token,会导致下一次刷新失败。因此脚本把完整的 OAuthToken 写回加密缓存,再使用新的只读 Access Token 连接 MCP。
如果 Refresh Token 已被轮换或失效,脚本会明确报错并要求重新授权。它不会回退到缓存中未确认 Scope 的旧 Access Token,因为这会让“只读验证”变成不可重现的测试。
一次真实验证结果:
read_scope_tools: ['get_user_profile']
read_with_read_scope: {"authenticated":true,"profile":{"name":"小明","city":"杭州","note":"喜欢周末去西湖散步"}}
write_with_read_scope: rejected ToolError
Refresh Token 可能采用轮换策略。旧 Refresh Token 使用后会失效,客户端必须保存服务端返回的新 Token,不能无限重复使用旧值。
12. 让 LangChain Agent 使用 OAuth MCP Tool
先确认 Qwen3 服务已经启动:
curl --noproxy '*' http://127.0.0.1:18080/v1/models
05_agent_with_oauth_mcp.py 把 OAuth 对象交给 MultiServerMCPClient:
mcp_client = MultiServerMCPClient(
{
"profiles": {
"transport": "http",
"url": "http://127.0.0.1:18130/mcp",
"auth": create_oauth(),
}
}
)
tools = await mcp_client.get_tools()
其余 Agent 代码与前面的 MCP Agent 一致:
with httpx.Client(trust_env=False) as http_client:
model = ChatOpenAI(
model="default_model",
base_url="http://127.0.0.1:18080/v1",
api_key="not-needed",
temperature=0,
max_tokens=160,
http_client=http_client,
)
agent = create_agent(
model=model,
tools=tools,
system_prompt="必须调用 MCP 用户资料工具回答问题,不要猜测。",
)
result = await agent.ainvoke(
{"messages": [{"role": "user", "content": "user-001 喜欢做什么?"}]}
)
一次真实运行结果:
OAuth MCP tools: ['get_user_profile']
Tool call: get_user_profile {'user_id': 'user-001'}
Tool result: {"authenticated":true,"profile":{"name":"小明","city":"杭州","note":"喜欢周末去西湖散步"}}
Qwen3 answer: 小明喜欢在周末去西湖散步。
Qwen3 没有接触 Keycloak 密码、Access Token 或 Refresh Token。它只看到 Tool Schema 和 Tool 返回的数据。
13. Token Factory 如何保护上游凭据
OAuthProxy 收到 Keycloak Token 后会完成三件事:
- 在服务端交换 Keycloak Authorization Code,加密保存上游 Access Token 与 Refresh Token,并建立 JTI 映射。
- 签发只面向 MCP Resource 的 Access Token,客户端不会收到 Keycloak Token。
- 每次 MCP 请求先验证下游 Token 和 JTI 映射,再使用 JWTVerifier 校验上游 Token 的签名、Issuer、Audience 和 Scope。
MCP Tool 通过 get_access_token() 读取的是框架交付的已验证授权上下文。工具只使用 preferred_username 做服务端资料归属判断,不复制原始 Keycloak Token,也不把完整 Claim 放入 Tool 返回值或 Prompt。该 Token Factory 行为与 FastMCP OAuthProxy 的参考实现一致。
14. 401、403 与 WWW-Authenticate
常见状态可以这样判断:
- 401:没有 Token、Token 无效、过期、Audience 错误或签名失败。
- 403:HTTP 语义上表示身份已经验证,但当前授权不足。
发生 401 时,Resource Server 通过 WWW-Authenticate 告诉客户端去哪里读取 Protected Resource Metadata。但在本文锁定的 fastmcp==3.3.1 中,require_scopes() 的组件级授权会先从 tools/list 过滤无权 Tool;如果客户端强行调用,实测收到的是 ToolError: Unknown tool,而不是可直接观察的 HTTP 403。不能把通用 HTTP 定义与这个框架的具体表现混在一起。
15. 安全边界
15.1 Token 不能放进 URL
Access Token 只能放在 Authorization 请求头中。URL 会进入浏览器历史、代理日志、监控系统和 Referer,更容易泄漏。
15.2 不允许开放任意回调地址
本例为了本机回调允许 localhost 和 127.0.0.1 的动态端口。生产客户端必须配置精确的 HTTPS 回调地址,不能使用任意域名通配符。
15.3 本机 HTTP 不能复制到生产环境
start-dev、HTTP 和非安全 Cookie 只适合本机。生产环境需要 HTTPS、安全 Cookie、反向代理、正式数据库、备份和审计。
15.4 不能把 MCP Token 透传给下游
Token 的 Audience 绑定 MCP Resource。把它转发给其他 API 会破坏受众隔离,也可能造成混淆代理攻击。访问下游服务时应使用该服务自己的凭据或 Token Exchange。
15.5 外部身份字段必须最小化
不要把 Keycloak 的完整 Token Payload 写进 AgentState、Prompt 或 Tool 返回值。只选择业务真正需要的稳定用户标识、租户或角色,并在服务器端完成映射。本文为了让本地演示更直观,使用 preferred_username 映射资料 ID;生产系统通常应使用不会随用户名修改而变化的 sub。
15.6 Scope 不等于数据归属权
profile:read 只说明调用者具备“读取资料”这类操作权限,不代表可以读取任意 user_id。示例还会把已验证的 preferred_username 映射到允许访问的资料 ID。真实系统应该用数据库中的租户、角色和对象归属关系执行这层检查。
16. 常见问题
16.1 Redirect URI 不匹配
FastMCP Client 默认使用:
http://localhost:<port>/callback
如果 OAuthProxy 只允许 127.0.0.1,授权端点会返回 invalid_request。本地配置必须同时考虑实际客户端使用的 Host,但生产环境不要扩大通配范围。
16.2 登录后一直等待
检查回调端口是否被占用,浏览器是否真正回到 localhost:18132/callback,以及 state 是否与最初请求一致。
16.3 Keycloak Token Audience 错误
Keycloak 客户端需要 Audience Mapper,把 mcp-oauth-proxy 写入上游 Access Token 的 aud。否则 JWTVerifier 会拒绝 Token。
16.4 直接向 Keycloak转发 resource
Keycloak 26.6.2 不支持 RFC 8707 Resource Indicators。本例必须设置 forward_resource=False,再由 OAuthProxy 生成面向 MCP Resource 的 Token。
16.5 Refresh Token 失效
Refresh Token 可能轮换。客户端需要保存新值,旧值收到 401 时应重新授权,不能在代码中反复重放。
16.6 重新生成配置后无法登录
01_prepare_local_config.py –force 会生成新的测试密码、客户端密钥和 OAuthProxy 签名密钥。如果此时仍保留旧 Keycloak 数据卷,.secrets 中的新配置就会和数据库中的旧配置不一致。
需要完全重置演示环境时,应先执行 docker compose down -v,再使用 –force 生成配置并重新启动 Keycloak。普通重启不要使用 –force。
17. 停止服务
停止 OAuthProxy 后,再停止 Keycloak:
docker compose \
-f mcp/p07_keycloak_oauth_agent/docker-compose.yml \
down
该命令保留 Keycloak 数据卷。只有确认需要完全重置演示环境时才使用:
docker compose \
-f mcp/p07_keycloak_oauth_agent/docker-compose.yml \
down -v
18. 总结
| 组件或机制 | 本文职责 | 不能误解为 |
|---|---|---|
| Keycloak | 用户登录、会话和上游 Scope 同意 | MCP Resource Server |
| OAuthProxy | MCP 授权发现、DCR、授权码、Token Factory | 简单 Token 透传代理 |
| Protected Resource Metadata | 告诉 Client Resource 和授权服务器位置 | 用户资料接口 |
| Authorization Server Metadata | 公布 authorize、token、register 和 PKCE 能力 | MCP Tool 列表 |
| Authorization Code | 浏览器授权后的一次性交换凭据 | Access Token |
| PKCE S256 | 绑定授权请求和 Token 交换 | 用户密码加密算法 |
| resource | 把授权请求绑定到 MCP Resource | Scope |
| Audience | 限制 Token 的目标服务 | 用户权限列表 |
| Scope | 限制允许执行的操作,本文据此过滤 Tool | 用户身份或数据归属关系 |
| 对象级授权 | 校验当前身份能否访问指定 user_id | 只要有 Scope 就能访问全部数据 |
| OAuthProxy Token Factory | 保存上游 Token,签发下游 MCP Token | 把 Keycloak Token 原样返回客户端 |
| LangChain MCP Adapter | 把 OAuth MCP Tool 交给 Agent | 负责用户登录或签发 Token |
至此,MCP 章节已经覆盖本地服务、公网服务、Python 与 Java Server、LangChain Agent 集成、JWT、Scope 和 OAuth 2.1。后续如果进入生产部署,还需要继续处理 HTTPS、正式数据库、多实例共享存储、密钥轮换、审计和高可用。