OAuth client sends discovery/registration requests with no User-Agent, so WAF-fronted servers (Cloudflare) 403 the whole flow
还没有人认领这个 Issue。
评估
- 难度
- 3/5
- 预计耗时
- 1-2 天
- 新手友好度
- 72/100
- Issue 类型
- 缺陷
- 描述清晰度
- 基本清楚
- 活跃度
- 活跃
- 技术栈
- python
- 领域
- api, authentication
调研方向
从 mcp/client/auth/utils.py 中的 create_oauth_metadata_request 和 create_client_registration_request 开始,然后跟踪 OAuthClientProvider.async_auth_flow 如何发送它们的请求。使用 MockTransport 复现来将请求头与 client.get() 进行比较;完成的标准是 discovery 和 registration 请求不再丢失必需的 User-Agent,并且通过 tests 或可比的本地 transport 验证生成的 OAuth 流程。
由索引模型根据 Issue 内容生成。
描述
Summary
OAuthClientProvider builds its OAuth discovery and registration requests as bare httpx.Request objects and sends them via client.send(). httpx merges a client's default headers only in build_request() (i.e. for client.get() / .post()), so these requests go out with no User-Agent and no Accept header.
Any MCP server behind a WAF that blocks user-agent-less traffic — Cloudflare's default bot management does — answers 403 to every one of them, making OAuth impossible to complete. The resulting error is also misattributed, which makes it hard to diagnose.
Reproduction
Against a Cloudflare-fronted MCP server (observed on https://mcp.services.biorender.com/mcp), with mcp==1.26.0:
POST /mcp -> 401 (expected; carries www-authenticate)
GET /.well-known/oauth-authorization-server -> 403 <-- blocked
GET /.well-known/oauth-protected-resource/mcp -> 403 <-- blocked
GET /.well-known/oauth-protected-resource -> 403 <-- blocked
GET /.well-known/oauth-authorization-server -> 403 <-- blocked
POST /register -> 403 <-- note the path
Isolating the trigger with curl against that same discovery URL:
normal curl (UA + Accept present) -> 200
curl -H 'User-Agent:' -H 'Accept:' -> 403
curl -H 'User-Agent:' -> 403 # UA alone is the trigger
curl -H 'Accept:' -> 200 # Accept is not
And confirming the header loss is structural, not server-specific:
import httpx, asyncio
seen = {}
def handler(req):
seen[req.url.path] = dict(req.headers)
return httpx.Response(200, json={})
async def main():
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as c:
await c.get('https://x.test/normal') # client.get()
await c.send(httpx.Request('GET', 'https://x.test/bare')) # what the SDK does
asyncio.run(main())
print('client.get() :', sorted(seen['/normal']))
print('client.send():', sorted(seen['/bare']))
client.get() : ['accept', 'accept-encoding', 'connection', 'host', 'user-agent']
client.send(): ['host']
Secondary problem: the failure is misreported
The 403 lands on metadata discovery, so context.oauth_metadata stays None. create_client_registration_request then falls back to urljoin(auth_base_url, "/register") — but this server's actual registration endpoint is /oauth/register, which its discovery document advertises correctly and which works fine when called with a User-Agent.
So the error surfaced to the user is Registration failed: 403 <cloudflare html>, pointing at a registration request to a path that was never the right one, when the real failure was four requests earlier. Anyone debugging this starts at the wrong end. (This is arguably worth addressing independently: a discovery failure could be reported as a discovery failure rather than silently degrading into a guessed-path registration.)
Affected code
mcp/client/auth/utils.py:211—create_oauth_metadata_request->Request("GET", url, headers={MCP_PROTOCOL_VERSION: ...})mcp/client/auth/utils.py:215-227—create_client_registration_request->Request("POST", registration_url, json=..., headers={"Content-Type": "application/json"})
Both construct Request outside the client, so neither inherits client defaults.
Suggested fix
Set a default User-Agent (e.g. mcp-python-sdk/<version>) on the requests these helpers build, or have OAuthClientProvider.async_auth_flow stamp one onto each request it yields when absent. Adding Accept: application/json to discovery would also be reasonable, though it is not what triggers the block here.
Workaround
Downstream clients can pass an httpx_client_factory that attaches a request event hook, since hooks fire for every request the client sends, including the auth flow's bare ones:
async def _stamp_user_agent(request: httpx.Request) -> None:
if "user-agent" not in request.headers:
request.headers["user-agent"] = "my-app/1.0"
def factory(headers=None, timeout=None, auth=None):
client = create_mcp_http_client(headers=headers, timeout=timeout, auth=auth)
client.event_hooks = {"request": [_stamp_user_agent], "response": []}
return client
Environment
mcp1.26.0,httpx0.28.1,httpx-sse0.4.1,anyio4.11.0- Python 3.11.13, Linux
- 主要语言
- Python
- 星标
- 24.3k
- 派生
- 4k
- 平均合并
- 1 天 1 小时
- 30 天内合并 PR
- 31
贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
modelcontextprotocol/python-sdk 的其他 Issue
-
v1 v2
难度 2/5 1-3 小时 新手友好度 85/100
modelcontextprotocol/python-sdk#3546 · 4 条评论 ·
-
v1 v2
难度 2/5 1-3 小时 新手友好度 76/100
modelcontextprotocol/python-sdk#3545 · 1 条评论 ·
-
v1 v2
难度 1/5 1 小时以内 新手友好度 91/100
modelcontextprotocol/python-sdk#3508 · 2 条评论 ·
-
难度 2/5 1-3 小时 新手友好度 64/100
modelcontextprotocol/python-sdk#3504 ·
-
v1 v2
难度 2/5 1-3 小时 新手友好度 82/100
modelcontextprotocol/python-sdk#3492 · 1 条评论 ·
查看 modelcontextprotocol/python-sdk 的全部 Issue
相似的 Issue
-
link-check link-check:sphinx-theme
难度 2/5 1-3 小时 新手友好度 72/100
-
难度 2/5 1-3 小时 新手友好度 65/100
qgis/QGIS-Documentation#11275 ·
-
bug priority:normal ready-for-dev
难度 2/5 1-3 小时 新手友好度 88/100
OpenHands/extensions#626 · 1 条评论 ·
-
难度 1/5 1 小时以内 新手友好度 90/100
CSCfi/sd-search-api#39 ·
-
难度 1/5 1 小时以内 新手友好度 90/100