feat(mcp): support OAuth authorization for remote MCP servers

This commit is contained in:
zhayujie
2026-07-13 12:00:14 +08:00
parent 42a5cf9538
commit 8c7cda89dc
8 changed files with 792 additions and 6 deletions

View File

@@ -1320,6 +1320,7 @@ class WebChannel(ChatChannel):
'/api/messages/delete', 'MessageDeleteHandler',
'/api/logs', 'LogsHandler',
'/api/version', 'VersionHandler',
'/mcp/oauth/callback', 'McpOAuthCallbackHandler',
'/assets/(.*)', 'AssetsHandler',
)
app = web.application(urls, globals(), autoreload=False)
@@ -1383,6 +1384,64 @@ class HealthHandler:
return json.dumps({"status": "ok"})
class McpOAuthCallbackHandler:
"""OAuth redirect target for MCP servers requiring authorization.
The browser lands here after the user authorizes a remote MCP server.
We exchange the authorization code for tokens and bring the server
online. Unauthenticated by design: the OAuth `state` param is the
single-use secret that binds this request to a pending authorization.
"""
def GET(self):
web.header('Content-Type', 'text/html; charset=utf-8')
params = web.input(code="", state="", error="", error_description="")
def _page(title: str, message: str) -> str:
return (
"<!doctype html><html><head><meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
f"<title>{title}</title></head>"
"<body style='font-family:-apple-system,Segoe UI,Roboto,sans-serif;"
"max-width:520px;margin:64px auto;padding:0 20px;text-align:center;color:#1f2328'>"
f"<h2>{title}</h2><p style='color:#57606a'>{message}</p></body></html>"
)
if params.error:
logger.warning(f"[MCP-OAuth] callback error: {params.error} {params.error_description}")
return _page("授权失败", f"{params.error}: {params.error_description or ''}")
if not params.code or not params.state:
return _page("参数缺失", "回调缺少 code 或 state 参数。")
try:
from agent.tools.mcp.mcp_oauth import pop_pending
from agent.tools.mcp.mcp_client import notify_server_authorized
except Exception as e:
logger.warning(f"[MCP-OAuth] callback import failed: {e}")
return _page("内部错误", "OAuth 模块不可用。")
handler = pop_pending(params.state)
if handler is None:
return _page("会话已过期", "授权请求不存在或已过期,请重新触发授权。")
try:
ok = handler.finish_authorization(params.code)
except Exception as e:
logger.warning(f"[MCP-OAuth] token exchange crashed: {e}")
ok = False
if not ok:
return _page("授权失败", "换取令牌失败,请重试。")
notify_server_authorized(handler.server_name)
logger.info(f"[MCP-OAuth] Server '{handler.server_name}' authorized via web callback")
return _page(
"授权成功",
f"MCP 服务 “{handler.server_name}” 已授权,可以返回聊天继续使用了。",
)
class AuthCheckHandler:
def GET(self):
web.header('Content-Type', 'application/json; charset=utf-8')