feat(mcp): add dedicated oauth_passthrough flag for non-oauth2 pass-through

Previously is_oauth_passthrough reused delegate_auth_to_upstream — a flag
scoped to oauth2 servers (PKCE bypass) — to gate OAuth pass-through for
auth_type=none servers. Overloading it risked regressing existing
deployments that set delegate_auth_to_upstream, since the same flag would
silently start driving pass-through (discovery proxying, 401 challenges,
upstream 401/403 propagation) on non-oauth2 servers.

Introduce a separate oauth_passthrough opt-in so the two behaviors never
imply each other:
- MCPServer.is_oauth_passthrough now requires oauth_passthrough (not
  delegate_auth_to_upstream).
- Persist oauth_passthrough on LiteLLM_MCPServerTable (new column +
  migration) and wire it through config/DB load and API responses.
- UI splits the single toggle into two: "Delegate auth to upstream (PKCE
  passthrough)" for oauth2 and "OAuth pass-through" for auth_type=none
  servers forwarding Authorization.

Adds backend tests (property, round-trip, and a regression guard that
delegate_auth_to_upstream alone never enables pass-through) and UI tests
for the toggle split.
This commit is contained in:
mateo-berri 2026-05-26 19:09:04 +00:00
parent ccea19d66f
commit 172c11b352
No known key found for this signature in database
20 changed files with 312 additions and 85 deletions

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN IF NOT EXISTS "oauth_passthrough" BOOLEAN NOT NULL DEFAULT false;

View file

@ -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?

View file

@ -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,

View file

@ -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

View file

@ -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?

View file

@ -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)

View file

@ -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?

View file

@ -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()

View file

@ -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()

View file

@ -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

View file

@ -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()

View file

@ -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(

View file

@ -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()

View file

@ -51,6 +51,68 @@ const renderWithForm = (props = {}) => {
expect(toggle).toHaveAttribute("aria-checked", "false");
});
const renderWithInitialValues = (
initialValues: Record<string, unknown>,
props = {},
) => {
const Wrapper: React.FC = ({ children }) => {
const [form] = Form.useForm();
return (
<Form form={form} initialValues={initialValues}>
{/* 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. */}
<Form.Item name="auth_type" hidden>
<input />
</Form.Item>
{children}
</Form>
);
};
return render(
<Wrapper>
<MCPPermissionManagement {...defaultProps} {...props} />
</Wrapper>,
);
};
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: {

View file

@ -31,13 +31,15 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
&& 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<MCPPermissionManagementProps> = ({
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 (
<Collapse className="bg-gray-50 border border-gray-200 rounded-lg" expandIconPosition="end" ghost={false}>
@ -140,27 +153,17 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
</Form.Item>
</div>
{canDelegateAuth && (
{isOAuth2 && (
<div className="flex items-start justify-between gap-4">
<div>
<span className="text-sm font-medium text-gray-700 flex items-center">
{isOAuth2
? "Delegate auth to upstream (PKCE passthrough)"
: "Delegate auth to upstream (OAuth pass-through)"}
<Tooltip
title={
isOAuth2
? "When on, LiteLLM skips its own API key/SSO check for this server and lets the client complete PKCE directly with the upstream MCP server. Only honored when Auth Type is oauth2. No spend tracking or per-key rate limiting will run on this route."
: "When on, this server is treated as an OAuth pass-through: the gateway proxies the upstream /.well-known/oauth-protected-resource metadata, emits spec-compliant 401 challenges when no bearer is supplied, and propagates upstream 401/403 responses. Only honored when Auth Type is None and 'Authorization' is in Extra Headers."
}
>
Delegate auth to upstream (PKCE passthrough)
<Tooltip title="When on, LiteLLM skips its own API key/SSO check for this server and lets the client complete PKCE directly with the upstream MCP server. Only honored when Auth Type is oauth2. No spend tracking or per-key rate limiting will run on this route.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
<p className="text-sm text-gray-600 mt-1">
{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.
</p>
</div>
<Form.Item
@ -174,6 +177,30 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
</div>
)}
{canEnableOAuthPassthrough && (
<div className="flex items-start justify-between gap-4">
<div>
<span className="text-sm font-medium text-gray-700 flex items-center">
OAuth pass-through
<Tooltip title="When on, this server is treated as an OAuth pass-through: the gateway proxies the upstream /.well-known/oauth-protected-resource metadata, emits spec-compliant 401 challenges when no bearer is supplied, and propagates upstream 401/403 responses. Only honored when Auth Type is None and 'Authorization' is in Extra Headers.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
<p className="text-sm text-gray-600 mt-1">
Forward upstream OAuth discovery and 401 challenges so clients negotiate OAuth directly with the upstream MCP server.
</p>
</div>
<Form.Item
name="oauth_passthrough"
valuePropName="checked"
initialValue={mcpServer?.oauth_passthrough ?? false}
className="mb-0"
>
<Switch />
</Form.Item>
</div>
)}
{showInternalDelegatePkceWarning && (
<Alert
type="warning"

View file

@ -288,6 +288,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
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<CreateMCPServerProps> = ({
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 }),
};

View file

@ -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(
<MCPServerEdit
mcpServer={{
...interactiveOAuthServer,
extra_headers: ["Authorization"],
oauth_passthrough: true,
}}
accessToken="access-token"
onCancel={vi.fn()}
onSuccess={vi.fn()}
availableAccessGroups={[]}
/>,
);
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)", () => {

View file

@ -385,6 +385,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
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<MCPServerEditProps> = ({
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<MCPServerEditProps> = ({
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

View file

@ -290,6 +290,27 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
</div>
</div>
)}
{handleAuth(mcpServer.auth_type) !== "oauth2" &&
Array.isArray(mcpServer.extra_headers) &&
mcpServer.extra_headers.some(
(h) => typeof h === "string" && h.toLowerCase() === "authorization",
) && (
<div className="py-3 grid grid-cols-3 gap-4">
<Text className="text-sm font-medium text-gray-500">OAuth Pass-through</Text>
<div className="col-span-2">
{mcpServer.oauth_passthrough ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-green-50 text-green-700 rounded-full border border-green-200 text-xs font-medium">
<span className="h-1.5 w-1.5 rounded-full bg-green-500"></span>
Enabled
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-gray-50 text-gray-600 rounded-full border border-gray-200 text-xs font-medium">
Disabled
</span>
)}
</div>
</div>
)}
<div className="py-3 grid grid-cols-3 gap-4">
<Text className="text-sm font-medium text-gray-500">Access Groups</Text>
<div className="col-span-2">

View file

@ -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;