1818 Dict ,
1919 List ,
2020 Optional ,
21+ Set ,
2122 Tuple ,
2223 Union ,
2324 cast ,
@@ -2935,15 +2936,32 @@ async def _raise_preemptive_401_for_unauthenticated_servers(
29352936 mcp_server_auth_headers : Optional [Dict [str , Dict [str , str ]]],
29362937 user_api_key_auth : Optional [UserAPIKeyAuth ],
29372938 client_ip : Optional [str ],
2939+ allowed_server_ids : Optional [Set [str ]] = None ,
29382940 ) -> None :
29392941 """Fail fast with HTTP 401 for MCP servers that need user auth but
29402942 didn't receive it on this request. Covers both gateway-managed OAuth2
29412943 (points clients at the gateway AS metadata) and pass-through OAuth
2942- (points clients at the upstream resource-metadata via our well-known)."""
2944+ (points clients at the upstream resource-metadata via our well-known).
2945+
2946+ ``allowed_server_ids`` may be passed by callers that have already
2947+ narrowed the authorized server set (e.g. toolset scoping); servers
2948+ not in that set are skipped so a client targeting a toolset that
2949+ excludes a passthrough server is not pushed into an OAuth flow for
2950+ a server it will be 403'd on immediately after authentication.
2951+ """
29432952 for server_name in mcp_servers or []:
29442953 server = global_mcp_server_manager .get_mcp_server_by_name (
29452954 server_name , client_ip = client_ip
29462955 )
2956+ if (
2957+ server is not None
2958+ and allowed_server_ids is not None
2959+ and server .server_id not in allowed_server_ids
2960+ ):
2961+ # Caller's narrowed scope excludes this server — skip the
2962+ # preemptive challenge and let downstream authorization
2963+ # return 403.
2964+ continue
29472965 if server and server .auth_type == MCPAuth .oauth2 and not oauth2_headers :
29482966 # For per-user OAuth servers, only skip the pre-emptive 401 when
29492967 # a stored token actually exists for this user+server pair.
@@ -3177,15 +3195,6 @@ async def handle_streamable_http_mcp(
31773195 verbose_logger .debug (
31783196 f"MCP server auth headers: { list (mcp_server_auth_headers .keys ()) if mcp_server_auth_headers else None } "
31793197 )
3180- # https://datatracker.ietf.org/doc/html/rfc9728#name-www-authenticate-response
3181- await _raise_preemptive_401_for_unauthenticated_servers (
3182- scope = scope ,
3183- mcp_servers = mcp_servers ,
3184- oauth2_headers = oauth2_headers ,
3185- mcp_server_auth_headers = mcp_server_auth_headers ,
3186- user_api_key_auth = user_api_key_auth ,
3187- client_ip = _client_ip ,
3188- )
31893198
31903199 # Strip any client-supplied x-mcp-toolset-id to prevent forgery.
31913200 scope ["headers" ] = [
@@ -3197,10 +3206,28 @@ async def handle_streamable_http_mcp(
31973206 # Apply toolset scope if set server-side via ContextVar (set by
31983207 # /toolset/{name}/mcp and /{name}/mcp route handlers in proxy_server.py).
31993208 active_toolset_id = _mcp_active_toolset_id .get ()
3209+ toolset_allowed_server_ids : Optional [Set [str ]] = None
32003210 if active_toolset_id and user_api_key_auth is not None :
32013211 user_api_key_auth = await _apply_toolset_scope (
32023212 user_api_key_auth , active_toolset_id
32033213 )
3214+ op = user_api_key_auth .object_permission
3215+ toolset_allowed_server_ids = set (op .mcp_servers or []) if op else set ()
3216+
3217+ # https://datatracker.ietf.org/doc/html/rfc9728#name-www-authenticate-response
3218+ # Must run after toolset scoping so the challenge set is derived
3219+ # from the fully-authorized server set: a passthrough server that
3220+ # the active toolset excludes should not trigger an OAuth flow
3221+ # for a server the caller will be 403'd on after authentication.
3222+ await _raise_preemptive_401_for_unauthenticated_servers (
3223+ scope = scope ,
3224+ mcp_servers = mcp_servers ,
3225+ oauth2_headers = oauth2_headers ,
3226+ mcp_server_auth_headers = mcp_server_auth_headers ,
3227+ user_api_key_auth = user_api_key_auth ,
3228+ client_ip = _client_ip ,
3229+ allowed_server_ids = toolset_allowed_server_ids ,
3230+ )
32043231
32053232 # Pre-flight auth check for pass-through servers. Must run after
32063233 # toolset scoping so the probe list is derived from the fully-authorized
@@ -3305,15 +3332,6 @@ async def handle_sse_mcp(scope: Scope, receive: Receive, send: Send) -> None:
33053332 verbose_logger .debug (
33063333 f"MCP server auth headers: { list (mcp_server_auth_headers .keys ()) if mcp_server_auth_headers else None } "
33073334 )
3308- # https://datatracker.ietf.org/doc/html/rfc9728#name-www-authenticate-response
3309- await _raise_preemptive_401_for_unauthenticated_servers (
3310- scope = scope ,
3311- mcp_servers = mcp_servers ,
3312- oauth2_headers = oauth2_headers ,
3313- mcp_server_auth_headers = mcp_server_auth_headers ,
3314- user_api_key_auth = user_api_key_auth ,
3315- client_ip = _sse_client_ip ,
3316- )
33173335
33183336 # Strip any client-supplied x-mcp-toolset-id to prevent forgery.
33193337 scope ["headers" ] = [
@@ -3326,10 +3344,28 @@ async def handle_sse_mcp(scope: Scope, receive: Receive, send: Send) -> None:
33263344 # downstream probe list matches the fully-authorized server set
33273345 # (mirrors the streamable HTTP handler).
33283346 active_toolset_id = _mcp_active_toolset_id .get ()
3347+ toolset_allowed_server_ids : Optional [Set [str ]] = None
33293348 if active_toolset_id and user_api_key_auth is not None :
33303349 user_api_key_auth = await _apply_toolset_scope (
33313350 user_api_key_auth , active_toolset_id
33323351 )
3352+ op = user_api_key_auth .object_permission
3353+ toolset_allowed_server_ids = set (op .mcp_servers or []) if op else set ()
3354+
3355+ # https://datatracker.ietf.org/doc/html/rfc9728#name-www-authenticate-response
3356+ # Must run after toolset scoping so the challenge set is derived
3357+ # from the fully-authorized server set: a passthrough server that
3358+ # the active toolset excludes should not trigger an OAuth flow
3359+ # for a server the caller will be 403'd on after authentication.
3360+ await _raise_preemptive_401_for_unauthenticated_servers (
3361+ scope = scope ,
3362+ mcp_servers = mcp_servers ,
3363+ oauth2_headers = oauth2_headers ,
3364+ mcp_server_auth_headers = mcp_server_auth_headers ,
3365+ user_api_key_auth = user_api_key_auth ,
3366+ client_ip = _sse_client_ip ,
3367+ allowed_server_ids = toolset_allowed_server_ids ,
3368+ )
33333369
33343370 # Pre-flight auth check for pass-through servers: surface upstream
33353371 # 401/403 as a proper challenge before the SSE session commits 200
0 commit comments