fix: MCP OAuth discovery for ChatGPT (RFC 9728 /mcp PRM, WWW-Authenticate)

- scopes_supported on protected-resource metadata
- /.well-known/oauth-protected-resource/mcp (path-prefix match)
- 401 on /mcp points resource_metadata at PRM URL; advertise scopes
- Prefer client_secret_basic in AS metadata (ChatGPT connector quirk)
- README: does not implement OAuth / 502 / 404 troubleshooting
This commit is contained in:
2026-06-25 13:53:31 +00:00
parent 07cf223d16
commit 59609f93c4
2 changed files with 41 additions and 13 deletions
+38 -13
View File
@@ -102,6 +102,27 @@ def intersect_oauth_scopes(client: dict, requested: str) -> str:
return " ".join(sorted(granted, key=lambda x: order.get(x, 9)))
def oauth_protected_resource_document(base: str, *, resource_path: str = "/mcp") -> dict:
"""RFC 9728 / MCP authorization discovery document."""
resource = f"{base.rstrip('/')}{resource_path}"
return {
"resource": resource,
"authorization_servers": [base.rstrip("/")],
"bearer_methods_supported": ["header"],
"scopes_supported": ["ctxd.read", "ctxd.write"],
"resource_documentation": f"{base.rstrip('/')}/",
}
def oauth_protected_resource_metadata_url(base: str, *, mcp_path: str = "/mcp") -> str:
"""Well-known URL for the MCP resource (RFC 9728 path insertion)."""
base = base.rstrip("/")
if mcp_path in ("", "/"):
return f"{base}/.well-known/oauth-protected-resource"
suffix = mcp_path.lstrip("/")
return f"{base}/.well-known/oauth-protected-resource/{suffix}"
def _is_public_oauth_as_route(path: str, method: str) -> bool:
"""OAuth authorization-server endpoints (public). Admin /oauth/clients stays on HTTPServer."""
if path.startswith("/.well-known/oauth-protected-resource"):
@@ -1513,12 +1534,7 @@ class CombinedApp:
qs = urllib.parse.parse_qs(scope.get("query_string", b"").decode())
if method == "GET" and path.startswith("/.well-known/oauth-protected-resource"):
payload = {
"resource": f"{base}/mcp",
"authorization_servers": [issuer],
"bearer_methods_supported": ["header"],
"resource_documentation": f"{base}/",
}
payload = oauth_protected_resource_document(base)
await self._send_json(send, 200, payload)
return
@@ -1531,7 +1547,11 @@ class CombinedApp:
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic", "none"],
"token_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post",
"none",
],
"scopes_supported": ["ctxd.read", "ctxd.write"],
}
await self._send_json(send, 200, payload)
@@ -1695,14 +1715,19 @@ class CombinedApp:
break
return token
async def _auth_error(status: int = 401, message: str = "unauthorized"):
async def _auth_error(status: int = 401, message: str = "unauthorized", *, mcp_resource: bool = False):
headers = [(b"content-type", b"application/json")]
if self.cfg.oauth_enabled:
base = self._base_url(scope)
headers.append((
b"www-authenticate",
f'Bearer resource_metadata="{base}/.well-known/oauth-protected-resource"'.encode(),
))
if mcp_resource:
meta_url = oauth_protected_resource_metadata_url(base, mcp_path="/mcp")
else:
meta_url = oauth_protected_resource_metadata_url(base)
www = (
f'Bearer resource_metadata="{meta_url}", '
f'scope="ctxd.read ctxd.write"'
)
headers.append((b"www-authenticate", www.encode()))
await send({
"type": "http.response.start",
"status": status,
@@ -1734,7 +1759,7 @@ class CombinedApp:
if ok:
await self._serve_oauth_mcp_streamable(scope, receive, send, ctx)
return
await _auth_error()
await _auth_error(mcp_resource=True)
return
# Legacy SSE write path (prefer /mcp Streamable HTTP).