Skip to content

Commit 625e3bc

Browse files
fix(mcp): scope preemptive 401 to toolset-narrowed server set
Move _raise_preemptive_401_for_unauthenticated_servers after toolset scoping in both the StreamableHTTP and SSE handlers, and add an optional allowed_server_ids parameter so passthrough/oauth2 servers that the active toolset excludes no longer trigger a spurious 401 challenge. Without this, a client targeting a toolset whose scope excludes a passthrough server could be pushed into an OAuth flow for a server it would be 403'd on immediately after authentication. Co-authored-by: Yassin Kortam <yassin@berri.ai>
1 parent 5a8adc3 commit 625e3bc

1 file changed

Lines changed: 55 additions & 19 deletions

File tree

  • litellm/proxy/_experimental/mcp_server

litellm/proxy/_experimental/mcp_server/server.py

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
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

Comments
 (0)