Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions litellm/experimental_mcp_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -520,13 +520,28 @@ async def _list_tools_operation(session: ClientSession):
# Return empty list instead of raising to allow graceful degradation
return []

@staticmethod
def error_tool_result(exc: Exception) -> MCPCallToolResult:
"""The error result ``call_tool`` returns when it swallows a failure (no re-execution)."""
return MCPCallToolResult(
content=[TextContent(type="text", text=f"{type(exc).__name__}: {str(exc)}")],
isError=True,
)

async def call_tool(
self,
call_tool_request_params: MCPCallToolRequestParams,
host_progress_callback: Optional[Callable] = None,
raise_on_error: bool = False,
) -> MCPCallToolResult:
"""
Call an MCP Tool.

Args:
raise_on_error: When True, re-raise the underlying exception instead of returning an
``isError=True`` result. The token-exchange (OBO) tool-call path uses this to detect
an upstream 401 so it can re-mint the exchanged token and retry once; every other
caller keeps the default and gets graceful ``isError`` degradation.
"""
verbose_logger.info(f"MCP client calling tool '{call_tool_request_params.name}'")

Expand Down Expand Up @@ -579,11 +594,10 @@ async def _call_tool_operation(session: ClientSession):
"MCP client detected broken connection/stream - "
"the MCP server may have crashed, disconnected, or timed out."
)
if raise_on_error:
raise
# Return a default error result instead of raising
return MCPCallToolResult(
content=[TextContent(type="text", text=f"{error_type}: {str(e)}")], # Empty content for error case
isError=True,
)
return self.error_tool_result(e)

async def list_prompts(self) -> List[Prompt]:
"""List available prompts from the server."""
Expand Down
49 changes: 49 additions & 0 deletions litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -1184,6 +1184,10 @@ async def _build_oauth_protected_resource_response(
detail=(f"Upstream oauth-protected-resource metadata unavailable for MCP server {mcp_server.name!r}"),
)

obo_response = _obo_protected_resource_response(mcp_server, resource_url)
if obo_response is not None:
return obo_response

return {
"authorization_servers": [
(f"{request_base_url}/{mcp_server_name}" if mcp_server_name else f"{request_base_url}")
Expand All @@ -1193,6 +1197,51 @@ async def _build_oauth_protected_resource_response(
}


def _obo_protected_resource_response(mcp_server: Optional[MCPServer], resource_url: str) -> Optional[dict]:
"""The OBO (token_exchange) PRM, or None when this server is not OBO / no issuer is configured.

The client SSOs with the IdP to obtain a subject token, which LiteLLM then exchanges, so discovery
points at the JWT-auth issuer(s) LiteLLM trusts (the same IdP that issues and validates the
subject), not the gateway. None falls the caller back to the gateway default so discovery still
returns metadata; it just can't name the IdP.
"""
if mcp_server is None or mcp_server.auth_type != MCPAuth.oauth2_token_exchange:
return None
issuers = _jwt_auth_issuers()
if not issuers:
return None
return {
"authorization_servers": issuers,
"resource": resource_url,
"scopes_supported": (mcp_server.scopes if mcp_server.scopes else []),
}


def _jwt_auth_issuers() -> list:
"""The OAuth issuer identifier(s) LiteLLM's JWT auth trusts, for the OBO PRM authorization_servers.

In token_exchange the IdP that issues the subject JWT is the same one LiteLLM validates it
against, so OBO discovery points clients at the JWT-auth issuer to obtain a subject token.
Sourced from ``JWT_ISSUER`` and any configured ``litellm_jwtauth.issuers``.
"""
import os # noqa: PLC0415

from litellm.proxy.proxy_server import general_settings # noqa: PLC0415

issuers: list = []
env_issuer = os.getenv("JWT_ISSUER")
if env_issuer:
issuers.append(env_issuer)

jwtauth = general_settings.get("litellm_jwtauth") if isinstance(general_settings, dict) else None
raw_issuers = jwtauth.get("issuers") if isinstance(jwtauth, dict) else getattr(jwtauth, "issuers", None)
for cfg in raw_issuers or []:
issuer = cfg.get("issuer") if isinstance(cfg, dict) else getattr(cfg, "issuer", None)
if issuer and issuer not in issuers:
issuers.append(issuer)
return issuers


# Standard MCP pattern: /.well-known/oauth-protected-resource/mcp/{server_name}
# This is the pattern expected by standard MCP clients (mcp-inspector, VSCode Copilot)
@router.get(
Expand Down
Loading
Loading