Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,7 @@ def _target_servers_delegate_auth_to_upstream(
# Inline imports avoid a circular dependency: mcp_server_manager imports
# from this module.
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
MCPServerManager,
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
Expand All @@ -382,7 +383,18 @@ def _target_servers_delegate_auth_to_upstream(
# fetches the upstream token automatically using stored credentials,
# so allowing anonymous bypass would let any external caller invoke
# tools authenticated as LiteLLM's service account.
if server.has_client_credentials:
#
# Resolve the flow rather than reading has_client_credentials directly:
# this is a security gate, and a legacy row whose oauth2_flow was never
# stamped still carries the M2M credential shape (client_id/secret +
# token_url, no authorization_url). Treating an unstamped-but-M2M-shaped
# row as non-M2M here would reopen the anonymous bypass the explicit
# column no longer closes on its own. Shares the one resolution helper
# with the egress backstop and the anonymous-delegate allowlist; all fail
# closed on the ambiguous shape and are removed together once no null rows
# remain. A pure-PKCE delegate server (no stored credentials) resolves to a
# non-M2M flow and keeps its bypass.
if MCPServerManager.effective_oauth2_flow(server) == "client_credentials":
return False
return True

Expand Down
114 changes: 90 additions & 24 deletions litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,21 @@ async def _elicitation_callback(context, params):
class MCPServerManager:
_STDIO_ENV_TEMPLATE_PATTERN = re.compile(r"^\$\{(X-[^}]+)\}$")

@staticmethod
def _explicit_oauth2_flow(
oauth2_flow: Optional[str],
) -> Optional[Literal["client_credentials", "authorization_code"]]:
"""DB rows persist their flow (write-time stamps plus the startup backfill) and
config servers must declare it (validated at load), so both builds read the
value verbatim: unknown or null resolves to None, which
``needs_user_oauth_token`` already treats as interactive. Field-shape inference
survives only in the request-time security helpers (``effective_oauth2_flow`` /
``resolve_oauth2_flow_for_request``).
"""
if oauth2_flow in ("client_credentials", "authorization_code"):
return cast(Literal["client_credentials", "authorization_code"], oauth2_flow)
return None

@staticmethod
def _resolve_oauth2_flow(
*,
Expand All @@ -521,11 +536,15 @@ def _resolve_oauth2_flow(
client_id: Optional[str],
client_secret: Optional[str],
) -> Optional[Literal["client_credentials", "authorization_code"]]:
"""Infer oauth2_flow for legacy records that omit the field.

DB rows created before oauth2_flow support may have OAuth2 client
credentials + token_url but a null oauth2_flow. Treat these as M2M,
unless authorization_url is present (interactive OAuth).
"""Infer oauth2_flow from field shape when the value is omitted.

Not called directly by security sites; they go through ``effective_oauth2_flow``
(boolean/enum decisions) or ``resolve_oauth2_flow_for_request`` (the egress object
backstop), which are the single choke points for request-time resolution. DB rows
are stamped at write time and by the startup backfill, config servers must declare
oauth2_flow (validated at load), and both builds read the value verbatim via
``_explicit_oauth2_flow``. Delete this whole request-time layer once the backstop
warning stays silent in production.
"""
if oauth2_flow in ("client_credentials", "authorization_code"):
return cast(Literal["client_credentials", "authorization_code"], oauth2_flow)
Expand All @@ -540,6 +559,51 @@ def _resolve_oauth2_flow(
return "client_credentials"
return None

@staticmethod
def effective_oauth2_flow(server: "MCPServer") -> Optional[Literal["client_credentials", "authorization_code"]]:
"""The oauth2_flow a security decision must use for ``server`` this request.

Column-first, shape-fallback: a stamped row returns its explicit value; an
unstamped (null) row whose fields carry the M2M shape resolves to
``client_credentials`` so it is treated as M2M and fails closed. Every
security-sensitive reader (anonymous-delegate allowlist and gate, egress flow
resolution) goes through this one helper rather than reading the bare
``has_client_credentials`` column, which is unreliable for null rows.
"""
return MCPServerManager._resolve_oauth2_flow(
auth_type=server.auth_type,
oauth2_flow=server.oauth2_flow,
token_url=server.token_url,
authorization_url=server.authorization_url,
client_id=server.client_id,
client_secret=server.client_secret,
)

@staticmethod
def resolve_oauth2_flow_for_request(server: "MCPServer") -> "MCPServer":
"""Return ``server`` with its effective oauth2_flow applied, for egress paths.

A stamped row is returned unchanged (its effective flow equals the stored value).
An unstamped M2M-shape row is returned as a per-request copy carrying
``oauth2_flow=client_credentials`` so downstream ``has_client_credentials`` /
``needs_user_oauth_token`` compute correctly and the stored client credentials are
used instead of forwarding the caller's Authorization. Use this at every point that
resolves an allowed server id into an ``MCPServer`` for a tool call or listing.
"""
effective = MCPServerManager.effective_oauth2_flow(server)
if effective is None or effective == server.oauth2_flow:
return server
verbose_logger.warning(
"MCP server %s has no persisted oauth2_flow but matches the %s shape; using the "
"inferred flow for this request. The startup backfill leaves this ambiguous M2M "
"shape unstamped on purpose, so it will NOT self-heal: set oauth2_flow explicitly "
"in the dashboard or via PUT /v1/mcp/server (client_credentials for M2M, or "
"authorization_code after an interactive sign-in).",
server.server_id,
effective,
)
return server.model_copy(update={"oauth2_flow": effective})

@staticmethod
def _obo_needs_endpoint_discovery(
auth_type: Optional[MCPAuthType],
Expand Down Expand Up @@ -772,6 +836,20 @@ async def load_servers_from_config(
mcp_oauth_metadata.registration_url if mcp_oauth_metadata else None
)

config_oauth2_flow = server_config.get("oauth2_flow", None)
if auth_type == MCPAuth.oauth2 and config_oauth2_flow not in (
"client_credentials",
"authorization_code",
):
raise ValueError(
f"Invalid config for MCP server '{server_name or server_id}': auth_type oauth2 "
f"requires an explicit oauth2_flow (got {config_oauth2_flow!r}). Set "
"oauth2_flow: client_credentials for machine-to-machine servers (the proxy mints "
"a shared token at token_url using client_id/client_secret, no user interaction) "
"or oauth2_flow: authorization_code for interactive servers (per-user tokens via "
"browser sign-in, including delegate_auth_to_upstream)."
)

new_server = MCPServer(
server_id=server_id,
name=name_for_prefix,
Expand All @@ -785,14 +863,7 @@ async def load_servers_from_config(
# oauth specific fields
client_id=server_config.get("client_id", None),
client_secret=server_config.get("client_secret", None),
oauth2_flow=self._resolve_oauth2_flow(
auth_type=auth_type,
oauth2_flow=server_config.get("oauth2_flow", None),
token_url=resolved_token_url,
authorization_url=resolved_authorization_url,
client_id=server_config.get("client_id", None),
client_secret=server_config.get("client_secret", None),
),
oauth2_flow=self._explicit_oauth2_flow(config_oauth2_flow),
scopes=resolved_scopes,
authorization_url=resolved_authorization_url,
token_url=resolved_token_url,
Expand Down Expand Up @@ -1170,15 +1241,7 @@ async def build_mcp_server_from_table(
env_vars=env_vars_list,
client_id=client_id_value or getattr(mcp_server, "client_id", None),
client_secret=client_secret_value or getattr(mcp_server, "client_secret", None),
oauth2_flow=self._resolve_oauth2_flow(
auth_type=auth_type,
oauth2_flow=getattr(mcp_server, "oauth2_flow", None),
token_url=mcp_server.token_url or getattr(mcp_oauth_metadata, "token_url", None),
authorization_url=mcp_server.authorization_url
or getattr(mcp_oauth_metadata, "authorization_url", None),
client_id=client_id_value or getattr(mcp_server, "client_id", None),
client_secret=client_secret_value or getattr(mcp_server, "client_secret", None),
),
oauth2_flow=self._explicit_oauth2_flow(getattr(mcp_server, "oauth2_flow", None)),
Comment thread
veria-ai[bot] marked this conversation as resolved.
Comment thread
tin-berri marked this conversation as resolved.
scopes=resolved_scopes,
authorization_url=mcp_server.authorization_url or getattr(mcp_oauth_metadata, "authorization_url", None),
token_url=mcp_server.token_url or getattr(mcp_oauth_metadata, "token_url", None),
Expand Down Expand Up @@ -1486,8 +1549,11 @@ async def get_allowed_mcp_servers(self, user_api_key_auth: Optional[UserAPIKeyAu
and getattr(server, "delegate_auth_to_upstream", False) is True
# M2M servers must not be exposed anonymously: an
# unauthenticated caller would get LiteLLM to proxy tool
# calls using its stored client_credentials.
and not server.has_client_credentials
# calls using its stored client_credentials. Resolve the flow
# rather than reading has_client_credentials so an unstamped
# M2M-shape row (null column, verbatim-read as non-M2M) still
# fails closed here, matching the anonymous-delegate auth gate.
and MCPServerManager.effective_oauth2_flow(server) != "client_credentials"
]
combined_servers.update(delegate_server_ids)

Expand Down
17 changes: 5 additions & 12 deletions litellm/proxy/_experimental/mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1427,18 +1427,8 @@ async def _get_allowed_mcp_servers(
for allowed_mcp_server_id in allowed_mcp_server_ids:
mcp_server = global_mcp_server_manager.get_mcp_server_by_id(allowed_mcp_server_id)
if mcp_server is not None:
# Apply oauth2_flow resolution for legacy DB rows where it may be NULL
resolved_flow = MCPServerManager._resolve_oauth2_flow(
auth_type=mcp_server.auth_type,
oauth2_flow=mcp_server.oauth2_flow,
token_url=mcp_server.token_url,
authorization_url=mcp_server.authorization_url,
client_id=mcp_server.client_id,
client_secret=mcp_server.client_secret,
)
if resolved_flow and resolved_flow != mcp_server.oauth2_flow:
# Create a new instance with the resolved flow for this request
mcp_server = mcp_server.model_copy(update={"oauth2_flow": resolved_flow})
# Apply the request-time oauth2_flow backstop for legacy null rows.
mcp_server = MCPServerManager.resolve_oauth2_flow_for_request(mcp_server)
allowed_mcp_servers.append(mcp_server)

if mcp_servers is not None:
Expand Down Expand Up @@ -2800,6 +2790,9 @@ async def call_mcp_tool(
for allowed_mcp_server_id in allowed_mcp_server_ids:
allowed_server = global_mcp_server_manager.get_mcp_server_by_id(allowed_mcp_server_id)
if allowed_server is not None:
# Same request-time oauth2_flow backstop the listing path applies,
# so a null-flow M2M-shape row is treated as M2M on tool calls too.
allowed_server = MCPServerManager.resolve_oauth2_flow_for_request(allowed_server)
allowed_mcp_servers.append(allowed_server)

allowed_mcp_servers = await _get_allowed_mcp_servers_from_mcp_server_names(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2146,6 +2146,104 @@ async def mock_auth_raises(*_args, **_kwargs):
assert exc_info.value.status_code == 401
mock_auth.assert_called_once()

async def test_delegate_ignored_for_unstamped_m2m_shaped_server(self):
"""
oauth2 + delegate + oauth2_flow=None but the M2M credential shape
(client_id/secret + token_url, no authorization_url) → bypass must NOT
fire. A legacy row that was never stamped still resolves to
client_credentials by shape, and reading the bare column here would
reopen the anonymous bypass to a server that runs upstream as LiteLLM's
service account. Fails closed like the client_credentials case above.
"""
from fastapi import HTTPException

from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer

scope = {
"type": "http",
"method": "POST",
"path": "/mcp/legacy_m2m_server",
"headers": [],
}

legacy_m2m_server = MCPServer(
server_id="legacy-m2m-id",
name="legacy_m2m_server",
transport="http",
auth_type=MCPAuth.oauth2,
delegate_auth_to_upstream=True,
oauth2_flow=None,
client_id="cid",
client_secret="csecret",
token_url="https://idp.example.com/token",
)
assert legacy_m2m_server.has_client_credentials is False

async def mock_auth_raises(*_args, **_kwargs):
raise HTTPException(status_code=401, detail="No key provided")

with (
patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth",
side_effect=mock_auth_raises,
) as mock_auth,
patch(
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager"
) as mock_mgr,
):
mock_mgr.get_mcp_server_by_name.return_value = legacy_m2m_server
with pytest.raises(HTTPException) as exc_info:
await MCPRequestHandler.process_mcp_request(scope)
assert exc_info.value.status_code == 401
mock_auth.assert_called_once()

async def test_delegate_bypass_for_pure_pkce_server(self):
"""
oauth2 + delegate + oauth2_flow=None and NO stored client credentials
(pure PKCE, the common delegate case) → bypass must still fire. The
shape resolves to a non-M2M flow, so the security gate leaves it alone;
the fail-closed rule targets the M2M shape specifically, not every
unstamped row.
"""
from litellm.types.mcp import MCPAuth
from litellm.types.mcp_server.mcp_server_manager import MCPServer

scope = {
"type": "http",
"method": "POST",
"path": "/mcp/pkce_server",
"headers": [],
}

pkce_server = MCPServer(
server_id="pkce-server-id",
name="pkce_server",
transport="http",
auth_type=MCPAuth.oauth2,
delegate_auth_to_upstream=True,
oauth2_flow=None,
)

async def mock_auth_raises(*_args, **_kwargs):
from fastapi import HTTPException

raise HTTPException(status_code=401, detail="No key provided")

with (
patch(
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth",
side_effect=mock_auth_raises,
) as mock_auth,
patch(
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager"
) as mock_mgr,
):
mock_mgr.get_mcp_server_by_name.return_value = pkce_server
auth, *_rest = await MCPRequestHandler.process_mcp_request(scope)
mock_auth.assert_not_called()
assert auth.api_key is None

async def test_delegate_bypass_for_internal_server(self):
"""
Delegate + oauth2 interactive servers bypass LiteLLM auth even when
Expand Down Expand Up @@ -2234,6 +2332,56 @@ async def test_get_allowed_servers_excludes_client_credentials_delegate(self):
assert "pkce-server" in result
assert "m2m-server" not in result

async def test_get_allowed_servers_excludes_unstamped_m2m_shape_delegate(self):
"""
The anonymous allow-list must also exclude an M2M-shape delegate server whose
oauth2_flow was never stamped (null column, verbatim-read as non-M2M). Reading
the bare has_client_credentials here would surface it to anonymous callers; the
resolved-flow check fails closed on the shape, matching the auth gate.
"""
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()
pkce_server = MCPServer(
server_id="pkce-server",
name="pkce_server",
transport="http",
auth_type=MCPAuth.oauth2,
delegate_auth_to_upstream=True,
available_on_public_internet=True,
)
unstamped_m2m = MCPServer(
server_id="unstamped-m2m",
name="unstamped_m2m",
transport="http",
auth_type=MCPAuth.oauth2,
delegate_auth_to_upstream=True,
oauth2_flow=None,
client_id="cid",
client_secret="csecret",
token_url="https://idp.example.com/token",
)
assert unstamped_m2m.has_client_credentials is False
manager.registry = {
pkce_server.server_id: pkce_server,
unstamped_m2m.server_id: unstamped_m2m,
}

with patch.object(
MCPRequestHandler,
"get_allowed_mcp_servers",
new_callable=AsyncMock,
return_value=[],
):
result = await manager.get_allowed_mcp_servers(None)

assert "pkce-server" in result
assert "unstamped-m2m" not in result

async def test_get_allowed_servers_includes_internal_delegate(self):
"""
Internal-only (available_on_public_internet=False) delegate servers
Expand Down
Loading
Loading