diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260526120000_add_oauth_passthrough_to_mcp_servers/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260526120000_add_oauth_passthrough_to_mcp_servers/migration.sql new file mode 100644 index 00000000000..3c387891a5e --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260526120000_add_oauth_passthrough_to_mcp_servers/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN IF NOT EXISTS "oauth_passthrough" BOOLEAN NOT NULL DEFAULT false; diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index 78143fe0411..c4754ef6117 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -325,6 +325,7 @@ model LiteLLM_MCPServerTable { allow_all_keys Boolean @default(false) available_on_public_internet Boolean @default(true) delegate_auth_to_upstream Boolean @default(false) + oauth_passthrough Boolean @default(false) is_byok Boolean @default(false) byok_description String[] @default([]) byok_api_key_help_url String? diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 47b2d2f1313..904331848d8 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -581,6 +581,7 @@ class MCPServerManager: delegate_auth_to_upstream=bool( server_config.get("delegate_auth_to_upstream", False) ), + oauth_passthrough=bool(server_config.get("oauth_passthrough", False)), # AWS SigV4 fields aws_access_key_id=server_config.get("aws_access_key_id", None), aws_secret_access_key=server_config.get("aws_secret_access_key", None), @@ -979,6 +980,7 @@ class MCPServerManager: delegate_auth_to_upstream=bool( getattr(mcp_server, "delegate_auth_to_upstream", False) ), + oauth_passthrough=bool(getattr(mcp_server, "oauth_passthrough", False)), created_at=getattr(mcp_server, "created_at", None), updated_at=getattr(mcp_server, "updated_at", None), tool_name_to_display_name=_deserialize_json_dict( @@ -3901,6 +3903,7 @@ class MCPServerManager: allow_all_keys=server.allow_all_keys, available_on_public_internet=server.available_on_public_internet, delegate_auth_to_upstream=server.delegate_auth_to_upstream, + oauth_passthrough=getattr(server, "oauth_passthrough", False), is_byok=server.is_byok, byok_description=server.byok_description, byok_api_key_help_url=server.byok_api_key_help_url, diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 3ae08e6a04c..75ae9e908e6 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1282,6 +1282,7 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase): allow_all_keys: bool = False available_on_public_internet: bool = True delegate_auth_to_upstream: bool = False + oauth_passthrough: bool = False is_byok: bool = False byok_description: List[str] = Field(default_factory=list) byok_api_key_help_url: Optional[str] = None @@ -1365,6 +1366,7 @@ class UpdateMCPServerRequest(LiteLLMPydanticObjectBase): allow_all_keys: bool = False available_on_public_internet: bool = True delegate_auth_to_upstream: bool = False + oauth_passthrough: bool = False is_byok: bool = False byok_description: List[str] = Field(default_factory=list) byok_api_key_help_url: Optional[str] = None @@ -1437,6 +1439,7 @@ class LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase): allow_all_keys: bool = False available_on_public_internet: bool = True delegate_auth_to_upstream: bool = False + oauth_passthrough: bool = False is_byok: bool = False byok_description: List[str] = Field(default_factory=list) byok_api_key_help_url: Optional[str] = None diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 78143fe0411..c4754ef6117 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -325,6 +325,7 @@ model LiteLLM_MCPServerTable { allow_all_keys Boolean @default(false) available_on_public_internet Boolean @default(true) delegate_auth_to_upstream Boolean @default(false) + oauth_passthrough Boolean @default(false) is_byok Boolean @default(false) byok_description String[] @default([]) byok_api_key_help_url String? diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index 438c2e283e4..6f4ccdb4f24 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -68,24 +68,29 @@ class MCPServer(BaseModel): access_groups: Optional[List[str]] = None allow_all_keys: bool = False available_on_public_internet: bool = True - # Explicit opt-in to upstream-delegated authentication. Two distinct modes - # depending on ``auth_type``: + # Explicit opt-in to upstream-delegated authentication for ``oauth2`` + # servers. When ``auth_type == oauth2`` and this is ``True``, MCP requests + # bypass LiteLLM API-key/SSO auth (and the pre-emptive 401) so the client + # completes PKCE directly with the upstream MCP server. See + # ``MCPRequestHandler._target_servers_delegate_auth_to_upstream``. # - # * ``auth_type == oauth2``: MCP requests bypass LiteLLM API-key/SSO - # auth (and the pre-emptive 401) so the client completes PKCE - # directly with the upstream MCP server. See - # ``MCPRequestHandler._target_servers_delegate_auth_to_upstream``. - # * ``auth_type in (None, MCPAuth.none)`` AND ``extra_headers`` contains - # ``Authorization``: enables OAuth pass-through (see - # ``is_oauth_passthrough``). The gateway proxies upstream - # ``/.well-known/oauth-protected-resource`` metadata, emits - # spec-compliant 401 challenges when no bearer is supplied, and - # propagates upstream 401/403 responses instead of swallowing them. - # - # Ignored for any other ``auth_type``. The flag must be set explicitly to - # avoid silently changing behavior for servers that forward - # ``Authorization`` for non-OAuth reasons (e.g. static bearer tokens). + # Honored only for ``auth_type == oauth2``; ignored for any other + # ``auth_type``. OAuth pass-through for non-oauth2 servers + # (``auth_type in (None, MCPAuth.none)``) is a separate, explicit opt-in — + # see ``oauth_passthrough`` / ``is_oauth_passthrough``. delegate_auth_to_upstream: bool = False + # Explicit opt-in to OAuth pass-through for non-oauth2 servers. When this + # is ``True`` AND ``auth_type in (None, MCPAuth.none)`` AND ``extra_headers`` + # contains ``Authorization``, the gateway proxies upstream + # ``/.well-known/oauth-protected-resource`` metadata, emits spec-compliant + # 401 challenges when no bearer is supplied, and propagates upstream + # 401/403 responses instead of swallowing them. See ``is_oauth_passthrough``. + # + # Intentionally distinct from ``delegate_auth_to_upstream`` (oauth2-only): + # reusing that flag would silently change behavior for servers that forward + # ``Authorization`` for non-OAuth reasons (e.g. static bearer tokens). Must + # be set explicitly to avoid regressing servers that did not opt in. + oauth_passthrough: bool = False is_byok: bool = False byok_description: List[str] = [] byok_api_key_help_url: Optional[str] = None @@ -163,12 +168,15 @@ class MCPServer(BaseModel): 2. ``extra_headers`` includes ``Authorization`` — the admin has opted this server into forwarding the client's bearer token straight to the upstream MCP server. - 3. ``delegate_auth_to_upstream`` is ``True`` — the admin has + 3. ``oauth_passthrough`` is ``True`` — the admin has explicitly opted into upstream-delegated OAuth semantics for this server. This is the explicit detection flag: without it, a server that merely forwards ``Authorization`` (e.g. for static bearer tokens or custom auth schemes) keeps the pre-PR behavior and is not treated as OAuth pass-through. + This is deliberately a separate flag from + ``delegate_auth_to_upstream`` (which is oauth2-only) so enabling + pass-through here never changes behavior for oauth2 servers. This is intentionally narrower than ``requires_per_user_auth``, which also covers PATs (``x-api-key``, ``api-key``, ``apikey``). @@ -179,7 +187,7 @@ class MCPServer(BaseModel): return False if not self.extra_headers: return False - if self.delegate_auth_to_upstream is not True: + if self.oauth_passthrough is not True: return False return any(h.lower() == "authorization" for h in self.extra_headers) diff --git a/schema.prisma b/schema.prisma index 78143fe0411..c4754ef6117 100644 --- a/schema.prisma +++ b/schema.prisma @@ -325,6 +325,7 @@ model LiteLLM_MCPServerTable { allow_all_keys Boolean @default(false) available_on_public_internet Boolean @default(true) delegate_auth_to_upstream Boolean @default(false) + oauth_passthrough Boolean @default(false) is_byok Boolean @default(false) byok_description String[] @default([]) byok_api_key_help_url String? diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py index 5ad915a670e..09b5d25bec5 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py @@ -876,7 +876,7 @@ class TestMCPPublicRouteGuard: with patch( "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", ) as mock_auth: - (auth_result, *_rest) = await MCPRequestHandler.process_mcp_request(scope) + auth_result, *_rest = await MCPRequestHandler.process_mcp_request(scope) mock_auth.assert_not_called() assert isinstance(auth_result, UserAPIKeyAuth) @@ -1114,7 +1114,7 @@ class TestMCPPassthroughColdStartAdmission: mock_mgr.get_mcp_server_by_name.return_value = ( TestMCPPassthroughColdStartAdmission._make_passthrough_server() ) - (auth_result, *_rest) = await MCPRequestHandler.process_mcp_request(scope) + auth_result, *_rest = await MCPRequestHandler.process_mcp_request(scope) assert isinstance(auth_result, UserAPIKeyAuth) mock_mgr.get_mcp_server_by_name.assert_any_call( @@ -1151,7 +1151,7 @@ class TestMCPPassthroughColdStartAdmission: mock_mgr.get_mcp_server_by_name.return_value = ( TestMCPPassthroughColdStartAdmission._make_passthrough_server() ) - (auth_result, *_rest) = await MCPRequestHandler.process_mcp_request(scope) + auth_result, *_rest = await MCPRequestHandler.process_mcp_request(scope) assert isinstance(auth_result, UserAPIKeyAuth) mock_mgr.get_mcp_server_by_name.assert_any_call( @@ -1276,7 +1276,7 @@ class TestMCPOAuth2FallbackTargetGating: mock_mgr.get_mcp_server_by_name.return_value = ( TestMCPOAuth2FallbackTargetGating._make_server(MCPAuth.oauth2) ) - (auth_result, *_rest) = await MCPRequestHandler.process_mcp_request(scope) + auth_result, *_rest = await MCPRequestHandler.process_mcp_request(scope) assert isinstance(auth_result, UserAPIKeyAuth) async def test_fallback_allowed_when_target_is_passthrough(self): @@ -1317,7 +1317,7 @@ class TestMCPOAuth2FallbackTargetGating: is_oauth_passthrough=True, ) ) - (auth_result, *_rest) = await MCPRequestHandler.process_mcp_request(scope) + auth_result, *_rest = await MCPRequestHandler.process_mcp_request(scope) assert isinstance(auth_result, UserAPIKeyAuth) assert auth_result.api_key is None @@ -1489,6 +1489,39 @@ class TestMCPDelegateAuthToUpstream: is False ) + def test_build_mcp_server_table_preserves_oauth_passthrough(self): + """Registry → API list rows must expose oauth_passthrough for the UI. + + ``oauth_passthrough`` is the dedicated non-oauth2 pass-through opt-in, + distinct from ``delegate_auth_to_upstream`` (oauth2-only). Both must + round-trip independently so neither flag silently implies the other. + """ + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + MCPServerManager, + ) + from litellm.types.mcp import MCPAuth + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + manager = MCPServerManager() + passthrough = MCPServer( + server_id="passthrough-1", + name="passthrough", + transport="http", + auth_type=MCPAuth.none, + extra_headers=["Authorization"], + oauth_passthrough=True, + available_on_public_internet=True, + ) + row = manager._build_mcp_server_table(passthrough) + assert row.oauth_passthrough is True + # The oauth2-only flag must remain independent and default off. + assert row.delegate_auth_to_upstream is False + + not_passthrough = passthrough.model_copy(update={"oauth_passthrough": False}) + assert ( + manager._build_mcp_server_table(not_passthrough).oauth_passthrough is False + ) + async def test_delegate_skips_litellm_auth_with_no_authorization(self): """ oauth2 + delegate_auth_to_upstream=True, no Authorization header at @@ -1518,7 +1551,7 @@ class TestMCPDelegateAuthToUpstream: delegate_auth_to_upstream=True, ) ) - (auth_result, *_rest) = await MCPRequestHandler.process_mcp_request(scope) + auth_result, *_rest = await MCPRequestHandler.process_mcp_request(scope) assert isinstance(auth_result, UserAPIKeyAuth) mock_auth.assert_not_called() @@ -1761,7 +1794,7 @@ class TestMCPDelegateAuthToUpstream: delegate_auth_to_upstream=True, ) ) - (auth_result, *_rest) = await MCPRequestHandler.process_mcp_request(scope) + auth_result, *_rest = await MCPRequestHandler.process_mcp_request(scope) assert isinstance(auth_result, UserAPIKeyAuth) assert auth_result.user_id == "real-user" mock_auth.assert_called_once() @@ -1798,7 +1831,7 @@ class TestMCPDelegateAuthToUpstream: delegate_auth_to_upstream=True, ) ) - (auth_result, *_rest) = await MCPRequestHandler.process_mcp_request(scope) + auth_result, *_rest = await MCPRequestHandler.process_mcp_request(scope) assert isinstance(auth_result, UserAPIKeyAuth) assert auth_result.user_id == "real-user" mock_auth.assert_called_once() diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough.py index 015b23c2fdf..ad78609ee18 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough.py @@ -70,7 +70,7 @@ def test_is_oauth_passthrough_true_when_none_auth_and_authorization_header(): transport=MCPTransport.http, auth_type=MCPAuth.none, extra_headers=["Authorization"], - delegate_auth_to_upstream=True, + oauth_passthrough=True, ) assert server.is_oauth_passthrough is True @@ -82,7 +82,7 @@ def test_is_oauth_passthrough_true_when_auth_type_none_and_mixed_case_header(): transport=MCPTransport.http, auth_type=None, extra_headers=["authorization", "x-request-id"], - delegate_auth_to_upstream=True, + oauth_passthrough=True, ) assert server.is_oauth_passthrough is True @@ -94,7 +94,7 @@ def test_is_oauth_passthrough_false_for_oauth2_server(): transport=MCPTransport.http, auth_type=MCPAuth.oauth2, extra_headers=["Authorization"], - delegate_auth_to_upstream=True, + oauth_passthrough=True, ) assert server.is_oauth_passthrough is False @@ -106,7 +106,7 @@ def test_is_oauth_passthrough_false_without_authorization_header(): transport=MCPTransport.http, auth_type=MCPAuth.none, extra_headers=["x-api-key"], - delegate_auth_to_upstream=True, + oauth_passthrough=True, ) assert server.is_oauth_passthrough is False @@ -117,12 +117,12 @@ def test_is_oauth_passthrough_false_without_extra_headers(): name="s1", transport=MCPTransport.http, auth_type=MCPAuth.none, - delegate_auth_to_upstream=True, + oauth_passthrough=True, ) assert server.is_oauth_passthrough is False -def test_is_oauth_passthrough_false_without_delegate_flag(): +def test_is_oauth_passthrough_false_without_oauth_passthrough_flag(): """The detection flag must be set explicitly. Without it, the legacy behavior is preserved for servers that forward Authorization for non-OAuth reasons (static bearer tokens, custom auth schemes).""" @@ -132,19 +132,38 @@ def test_is_oauth_passthrough_false_without_delegate_flag(): transport=MCPTransport.http, auth_type=MCPAuth.none, extra_headers=["Authorization"], - # delegate_auth_to_upstream defaults to False + # oauth_passthrough defaults to False ) assert server.is_oauth_passthrough is False -def test_is_oauth_passthrough_false_when_delegate_flag_explicitly_false(): +def test_is_oauth_passthrough_false_when_oauth_passthrough_explicitly_false(): server = MCPServer( server_id="s1", name="s1", transport=MCPTransport.http, auth_type=MCPAuth.none, extra_headers=["Authorization"], - delegate_auth_to_upstream=False, + oauth_passthrough=False, + ) + assert server.is_oauth_passthrough is False + + +def test_is_oauth_passthrough_false_when_only_delegate_auth_to_upstream_set(): + """Regression guard: ``delegate_auth_to_upstream`` is the oauth2-only + PKCE-bypass flag and must NOT, on its own, turn a non-oauth2 server into + an OAuth pass-through server. Pass-through requires the dedicated + ``oauth_passthrough`` opt-in. This protects existing deployments that set + ``delegate_auth_to_upstream`` from silently gaining pass-through behavior. + """ + server = MCPServer( + server_id="s1", + name="s1", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + extra_headers=["Authorization"], + delegate_auth_to_upstream=True, + # oauth_passthrough intentionally left at its default (False) ) assert server.is_oauth_passthrough is False @@ -170,7 +189,7 @@ async def test_oauth_protected_resource_passthrough_proxies_upstream_metadata(): transport=MCPTransport.http, auth_type=MCPAuth.none, extra_headers=["Authorization"], - delegate_auth_to_upstream=True, + oauth_passthrough=True, ) global_mcp_server_manager.registry[passthrough_server.server_id] = ( passthrough_server @@ -221,7 +240,7 @@ async def test_oauth_protected_resource_passthrough_cache_hit(): transport=MCPTransport.http, auth_type=MCPAuth.none, extra_headers=["Authorization"], - delegate_auth_to_upstream=True, + oauth_passthrough=True, ) global_mcp_server_manager.registry[passthrough_server.server_id] = ( passthrough_server @@ -314,7 +333,7 @@ async def test_oauth_metadata_cache_expired_entry_is_refetched(): transport=MCPTransport.http, auth_type=MCPAuth.none, extra_headers=["Authorization"], - delegate_auth_to_upstream=True, + oauth_passthrough=True, ) _OAUTH_METADATA_CACHE[(passthrough_server.server_id, passthrough_server.url)] = ( 0, @@ -356,7 +375,7 @@ async def test_oauth_protected_resource_passthrough_network_error_returns_502(): transport=MCPTransport.http, auth_type=MCPAuth.none, extra_headers=["Authorization"], - delegate_auth_to_upstream=True, + oauth_passthrough=True, ) global_mcp_server_manager.registry[passthrough_server.server_id] = ( passthrough_server @@ -389,7 +408,7 @@ async def test_fetch_upstream_metadata_returns_none_when_not_all_candidates_netw transport=MCPTransport.http, auth_type=MCPAuth.none, extra_headers=["Authorization"], - delegate_auth_to_upstream=True, + oauth_passthrough=True, ) not_found_response = MagicMock() diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough_cold_start.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough_cold_start.py index f0f32a65708..3e934577a66 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough_cold_start.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough_cold_start.py @@ -60,7 +60,7 @@ def test_passthrough_cold_start_emits_401_with_matching_resource_metadata( transport=MCPTransport.http, auth_type=MCPAuth.none, extra_headers=["Authorization"], - delegate_auth_to_upstream=True, + oauth_passthrough=True, ) global_mcp_server_manager.registry[passthrough_server.server_id] = ( passthrough_server diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough_tools.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough_tools.py index aa36cf88155..cfaaf21c0dc 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough_tools.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough_tools.py @@ -60,7 +60,7 @@ async def test_fetch_tools_from_passthrough_raises_on_upstream_401(): transport=MCPTransport.http, auth_type=MCPAuth.none, extra_headers=["Authorization"], - delegate_auth_to_upstream=True, + oauth_passthrough=True, ) response = httpx.Response( @@ -98,7 +98,7 @@ async def test_fetch_tools_from_passthrough_returns_tools_on_success(): transport=MCPTransport.http, auth_type=MCPAuth.none, extra_headers=["Authorization"], - delegate_auth_to_upstream=True, + oauth_passthrough=True, ) tool = MagicMock() diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py index 2eef3a8e990..2ba7f79e7d3 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py @@ -153,7 +153,7 @@ def test_prepare_mcp_server_headers_passthrough_strips_authorization_without_adm transport=MCPTransport.http, auth_type=MCPAuth.none, extra_headers=["Authorization", "x-request-id"], - delegate_auth_to_upstream=True, + oauth_passthrough=True, ) server_auth_header, extra_headers = _prepare_mcp_server_headers( @@ -191,7 +191,7 @@ def test_prepare_mcp_server_headers_passthrough_forwards_authorization_for_anony transport=MCPTransport.http, auth_type=MCPAuth.none, extra_headers=["Authorization", "x-request-id"], - delegate_auth_to_upstream=True, + oauth_passthrough=True, ) server_auth_header, extra_headers = _prepare_mcp_server_headers( @@ -233,7 +233,7 @@ def test_prepare_mcp_server_headers_passthrough_strips_authorization_for_authent transport=MCPTransport.http, auth_type=MCPAuth.none, extra_headers=["Authorization", "x-request-id"], - delegate_auth_to_upstream=True, + oauth_passthrough=True, ) server_auth_header, extra_headers = _prepare_mcp_server_headers( diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index bbf0f7df7bb..c8decd85da2 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -497,7 +497,7 @@ class TestMCPServerManager: transport=MCPTransport.http, auth_type=MCPAuth.none, extra_headers=["Authorization", "x-request-id"], - delegate_auth_to_upstream=True, + oauth_passthrough=True, ) mock_client = AsyncMock() @@ -550,7 +550,7 @@ class TestMCPServerManager: transport=MCPTransport.http, auth_type=MCPAuth.none, extra_headers=["Authorization"], - delegate_auth_to_upstream=True, + oauth_passthrough=True, ) mock_client = AsyncMock() @@ -606,7 +606,7 @@ class TestMCPServerManager: transport=MCPTransport.http, auth_type=MCPAuth.none, extra_headers=["Authorization"], - delegate_auth_to_upstream=True, + oauth_passthrough=True, ) mock_client = AsyncMock() diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.test.tsx index 393c9e4a619..2cbfce320af 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.test.tsx @@ -51,6 +51,68 @@ const renderWithForm = (props = {}) => { expect(toggle).toHaveAttribute("aria-checked", "false"); }); + const renderWithInitialValues = ( + initialValues: Record, + props = {}, + ) => { + const Wrapper: React.FC = ({ children }) => { + const [form] = Form.useForm(); + return ( +
+ {/* In the real app auth_type is registered by the parent form; the + component only watches it. Register a hidden field here so + Form.useWatch("auth_type") resolves the initial value. */} + + {children} +
+ ); + }; + return render( + + + , + ); + }; + + it("shows only the oauth2 PKCE-delegation toggle for oauth2 servers", async () => { + renderWithInitialValues({ allow_all_keys: false, auth_type: "oauth2" }); + await expandPanel(); + expect( + screen.getByText("Delegate auth to upstream (PKCE passthrough)"), + ).toBeInTheDocument(); + // The non-oauth2 pass-through toggle must NOT appear for oauth2 servers. + expect(screen.queryByText("OAuth pass-through")).not.toBeInTheDocument(); + }); + + it("shows only the OAuth pass-through toggle for none-auth servers forwarding Authorization", async () => { + renderWithInitialValues({ + allow_all_keys: false, + auth_type: "none", + extra_headers: ["Authorization"], + }); + await expandPanel(); + expect(screen.getByText("OAuth pass-through")).toBeInTheDocument(); + // The oauth2-only PKCE delegation toggle must NOT appear here. + expect( + screen.queryByText("Delegate auth to upstream (PKCE passthrough)"), + ).not.toBeInTheDocument(); + }); + + it("hides both upstream-auth toggles for none-auth servers without an Authorization header", async () => { + renderWithInitialValues({ + allow_all_keys: false, + auth_type: "none", + extra_headers: ["x-api-key"], + }); + await expandPanel(); + expect(screen.queryByText("OAuth pass-through")).not.toBeInTheDocument(); + expect( + screen.queryByText("Delegate auth to upstream (PKCE passthrough)"), + ).not.toBeInTheDocument(); + }); + it("should reflect allow_all_keys when editing an existing server", async () => { renderWithForm({ mcpServer: { diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.tsx b/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.tsx index 4aaca2a274b..b5f0fa2e7eb 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/MCPPermissionManagement.tsx @@ -31,13 +31,15 @@ const MCPPermissionManagement: React.FC = ({ && watchedExtraHeaders.some( (h) => typeof h === "string" && h.toLowerCase() === "authorization", ); - // Two modes that honor delegate_auth_to_upstream server-side: - // - oauth2 servers (PKCE passthrough — bypass LiteLLM admission) - // - auth_type=none + Authorization in extra_headers (OAuth pass-through: - // proxy upstream oauth-protected-resource, emit 401 challenges, - // propagate upstream 401/403) - const isOAuthPassthrough = isNoneAuth && hasAuthorizationHeader; - const canDelegateAuth = isOAuth2 || isOAuthPassthrough; + // Two distinct, independent opt-ins: + // - delegate_auth_to_upstream: oauth2 servers only (PKCE passthrough — + // bypass LiteLLM admission). + // - oauth_passthrough: auth_type=none + Authorization in extra_headers + // (OAuth pass-through: proxy upstream oauth-protected-resource, emit 401 + // challenges, propagate upstream 401/403). + // Kept as separate flags so neither silently implies the other and existing + // oauth2 servers can't regress into pass-through behavior. + const canEnableOAuthPassthrough = isNoneAuth && hasAuthorizationHeader; const watchedDelegateAuth = Form.useWatch("delegate_auth_to_upstream", form); const watchedPublicInternet = Form.useWatch("available_on_public_internet", form); const showInternalDelegatePkceWarning = @@ -64,22 +66,33 @@ const MCPPermissionManagement: React.FC = ({ if (typeof mcpServer.delegate_auth_to_upstream === "boolean") { form.setFieldValue("delegate_auth_to_upstream", mcpServer.delegate_auth_to_upstream); } + if (typeof mcpServer.oauth_passthrough === "boolean") { + form.setFieldValue("oauth_passthrough", mcpServer.oauth_passthrough); + } } else { form.setFieldValue("allow_all_keys", false); form.setFieldValue("available_on_public_internet", true); form.setFieldValue("delegate_auth_to_upstream", false); + form.setFieldValue("oauth_passthrough", false); } }, [mcpServer, form]); - // delegate_auth_to_upstream is only honored server-side for oauth2 servers - // or auth_type=none servers that forward Authorization to upstream. Force - // it back to false whenever the user switches to any other configuration - // so a stale toggle value doesn't get persisted unexpectedly. + // delegate_auth_to_upstream is only honored server-side for oauth2 servers. + // Force it back to false whenever the user switches away from oauth2 so a + // stale toggle value doesn't get persisted unexpectedly. useEffect(() => { - if (!canDelegateAuth) { + if (!isOAuth2) { form.setFieldValue("delegate_auth_to_upstream", false); } - }, [canDelegateAuth, form]); + }, [isOAuth2, form]); + + // oauth_passthrough is only honored for auth_type=none servers that forward + // Authorization upstream. Force it back to false otherwise. + useEffect(() => { + if (!canEnableOAuthPassthrough) { + form.setFieldValue("oauth_passthrough", false); + } + }, [canEnableOAuthPassthrough, form]); return ( @@ -140,27 +153,17 @@ const MCPPermissionManagement: React.FC = ({ - {canDelegateAuth && ( + {isOAuth2 && (
- {isOAuth2 - ? "Delegate auth to upstream (PKCE passthrough)" - : "Delegate auth to upstream (OAuth pass-through)"} - + Delegate auth to upstream (PKCE passthrough) +

- {isOAuth2 - ? "Bypass LiteLLM auth so clients authenticate directly with the upstream OAuth MCP server." - : "Forward upstream OAuth discovery and 401 challenges so clients negotiate OAuth directly with the upstream MCP server."} + Bypass LiteLLM auth so clients authenticate directly with the upstream OAuth MCP server.

= ({
)} + {canEnableOAuthPassthrough && ( +
+
+ + OAuth pass-through + + + + +

+ Forward upstream OAuth discovery and 401 challenges so clients negotiate OAuth directly with the upstream MCP server. +

+
+ + + +
+ )} + {showInternalDelegatePkceWarning && ( = ({ allow_all_keys: allowAllKeysRaw, available_on_public_internet: availableOnPublicInternetRaw, delegate_auth_to_upstream: delegateAuthToUpstreamRaw, + oauth_passthrough: oauthPassthroughRaw, token_validation_json: rawTokenValidationJson, ...restValues } = values; @@ -393,6 +394,7 @@ const CreateMCPServer: React.FC = ({ allow_all_keys: Boolean(allowAllKeysRaw), available_on_public_internet: Boolean(availableOnPublicInternetRaw), delegate_auth_to_upstream: Boolean(delegateAuthToUpstreamRaw), + oauth_passthrough: Boolean(oauthPassthroughRaw), static_headers: staticHeaders, ...(tokenValidation !== null && { token_validation: tokenValidation }), }; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx index 1f2864f6759..9fef034123f 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx @@ -216,6 +216,41 @@ describe("MCPServerEdit (delegate auth)", () => { expect(payload.auth_type).toBe("none"); expect(payload.delegate_auth_to_upstream).toBe(false); }); + + it("does not enable oauth_passthrough for an oauth2 server", async () => { + vi.mocked(networking.updateMCPServer).mockResolvedValue({ + ...interactiveOAuthServer, + oauth_passthrough: false, + }); + + render( + , + ); + + const saveButtons = screen.getAllByRole("button", { name: "Save Changes" }); + await act(async () => { + fireEvent.click(saveButtons[0]); + }); + + await waitFor(() => { + expect(networking.updateMCPServer).toHaveBeenCalledTimes(1); + }); + + const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0]; + expect(payload.auth_type).toBe("oauth2"); + // oauth_passthrough is non-oauth2 only — must be forced false here. + expect(payload.oauth_passthrough).toBe(false); + }); }); describe("MCPServerEdit (interactive OAuth)", () => { diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx index cf3e33b1c57..6b79ec3e52a 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx @@ -385,6 +385,7 @@ const MCPServerEdit: React.FC = ({ allow_all_keys: allowAllKeysRaw, available_on_public_internet: availableOnPublicInternetRaw, delegate_auth_to_upstream: delegateAuthToUpstreamRaw, + oauth_passthrough: oauthPassthroughRaw, token_validation_json: rawTokenValidationJson, ...restValues } = values; @@ -554,14 +555,22 @@ const MCPServerEdit: React.FC = ({ allow_all_keys: Boolean(allowAllKeysRaw ?? mcpServer.allow_all_keys), available_on_public_internet: Boolean(availableOnPublicInternetRaw ?? mcpServer.available_on_public_internet), // ``delegate_auth_to_upstream`` is only honored server-side for - // ``auth_type=oauth2`` (PKCE passthrough) or ``auth_type=none`` with - // ``Authorization`` in ``extra_headers`` (OAuth pass-through). The - // Form.Item is conditionally rendered so the value drops out of the - // form on auth_type change; force false for any other configuration - // to avoid persisting a stale ``true`` that would silently - // re-activate if the configuration is later switched back. + // ``auth_type=oauth2`` (PKCE passthrough). The Form.Item is + // conditionally rendered so the value drops out of the form on + // auth_type change; force false for any other configuration to avoid + // persisting a stale ``true`` that would silently re-activate if the + // configuration is later switched back. delegate_auth_to_upstream: (() => { const isOauth2 = restValues.auth_type === AUTH_TYPE.OAUTH2; + return isOauth2 + ? Boolean(delegateAuthToUpstreamRaw ?? mcpServer.delegate_auth_to_upstream) + : false; + })(), + // ``oauth_passthrough`` is the dedicated, non-oauth2 opt-in. It is only + // honored for ``auth_type=none`` servers that forward ``Authorization`` + // upstream. Kept separate from ``delegate_auth_to_upstream`` so enabling + // pass-through never regresses oauth2 servers. Force false otherwise. + oauth_passthrough: (() => { const isNoneAuth = restValues.auth_type === AUTH_TYPE.NONE || restValues.auth_type == null; const extraHeaders = Array.isArray(restValues.extra_headers) @@ -570,9 +579,8 @@ const MCPServerEdit: React.FC = ({ const hasAuthorizationHeader = extraHeaders.some( (h: unknown) => typeof h === "string" && h.toLowerCase() === "authorization", ); - const eligible = isOauth2 || (isNoneAuth && hasAuthorizationHeader); - return eligible - ? Boolean(delegateAuthToUpstreamRaw ?? mcpServer.delegate_auth_to_upstream) + return isNoneAuth && hasAuthorizationHeader + ? Boolean(oauthPassthroughRaw ?? mcpServer.oauth_passthrough) : false; })(), // Include token_validation when it is set (non-null) or when clearing an existing value diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx index 5a8035d4e0b..87e8b77837c 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_view.tsx @@ -290,6 +290,27 @@ export const MCPServerView: React.FC = ({ )} + {handleAuth(mcpServer.auth_type) !== "oauth2" && + Array.isArray(mcpServer.extra_headers) && + mcpServer.extra_headers.some( + (h) => typeof h === "string" && h.toLowerCase() === "authorization", + ) && ( +
+ OAuth Pass-through +
+ {mcpServer.oauth_passthrough ? ( + + + Enabled + + ) : ( + + Disabled + + )} +
+
+ )}
Access Groups
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx index 9a8f2e8f514..c198cf1b9b1 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -210,6 +210,7 @@ export interface MCPServer { allow_all_keys?: boolean; available_on_public_internet?: boolean; delegate_auth_to_upstream?: boolean; + oauth_passthrough?: boolean; /** Stdio-only fields (present when transport === 'stdio') */ command?: string | null;