Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/_experimental/mcp_server/exceptions.py: 42%

32 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 12:01 +0000

1"""Exceptions raised by the LiteLLM MCP proxy.""" 

2 

3from typing import Final 

4 

5from fastapi import HTTPException 

6 

7 

8class MCPServerURLCredentialsError(HTTPException): 

9 """A fixed, sanitized URL-credential migration error safe for operator previews.""" 

10 

11 def __init__(self) -> None: 

12 super().__init__( 

13 status_code=500, 

14 detail=( 

15 "misconfigured: auth_type none cannot be used with credentials embedded in the upstream URL; " 

16 "remove them from the URL and configure Basic Auth with auth_type: basic and " 

17 "auth_value: username:password" 

18 ), 

19 ) 

20 

21 

22class MCPUpstreamAuthError(Exception): 

23 """Raised when an upstream MCP server returns an authentication failure 

24 (typically HTTP 401) and the gateway should surface it transparently to 

25 the client instead of swallowing it. 

26 

27 Relevant for MCP servers that delegate OAuth to the upstream server, 

28 including pass-through servers and OAuth2 servers with 

29 ``delegate_auth_to_upstream`` enabled. The gateway converts this exception 

30 into an HTTP 401 response on single-server routes, preserving any 

31 ``WWW-Authenticate`` challenge emitted by the upstream so standards- 

32 compliant MCP clients can trigger the upstream OAuth flow. 

33 """ 

34 

35 def __init__( 

36 self, 

37 status_code: int, 

38 www_authenticate: str | None, 

39 server_name: str, 

40 ) -> None: 

41 self.status_code = status_code 

42 self.www_authenticate = www_authenticate 

43 self.server_name = server_name 

44 super().__init__(f"Upstream MCP server {server_name!r} returned {status_code}") 

45 

46 def to_http_exception( 

47 self, 

48 base_url: str | None = None, 

49 request_path: str | None = None, 

50 ) -> HTTPException: 

51 """Convert this upstream-auth error into an ``HTTPException`` that 

52 preserves the upstream status code and any ``WWW-Authenticate`` 

53 challenge, so standards-compliant MCP clients can trigger the 

54 upstream OAuth flow. 

55 

56 When the upstream 401 omits ``WWW-Authenticate`` (non-compliant per 

57 RFC 7235 §3.1) we fabricate a ``Bearer resource_metadata=`` challenge 

58 that points at the gateway's well-known endpoint for this server, so 

59 MCP clients can still initiate RFC 9728 discovery against the upstream 

60 IdP via the gateway's proxied metadata. Callers must pass ``base_url`` 

61 (the gateway origin, no trailing slash) so the fabricated URI is 

62 absolute as RFC 9728 §3.2 requires; if ``base_url`` is missing we 

63 skip fabrication entirely rather than emit a relative URI that strict 

64 clients reject in the Bearer challenge. 

65 

66 When ``request_path`` is supplied and matches the legacy 

67 ``/{server_name}/mcp`` MCP transport route, the fabricated URI uses 

68 the matching legacy well-known form 

69 ``/.well-known/oauth-protected-resource/{server_name}/mcp``. Otherwise 

70 we default to the standard form 

71 ``/.well-known/oauth-protected-resource/mcp/{server_name}``. This 

72 keeps the ``resource_metadata`` URI aligned with the resource pattern 

73 the client originally targeted, matching the path-aware behaviour of 

74 ``get_passthrough_resource_metadata_url`` in ``oauth_utils.py``. 

75 """ 

76 challenge: str | None = self.www_authenticate 

77 if challenge is None and self.status_code == 401 and base_url: 

78 prefix: Final = base_url.rstrip("/") 

79 if request_path and request_path.startswith(f"/{self.server_name}/mcp"): 

80 resource_metadata_url = f"{prefix}/.well-known/oauth-protected-resource/{self.server_name}/mcp" 

81 else: 

82 resource_metadata_url = f"{prefix}/.well-known/oauth-protected-resource/mcp/{self.server_name}" 

83 challenge = f'Bearer resource_metadata="{resource_metadata_url}"' 

84 detail: Final = "Forbidden" if self.status_code == 403 else "Unauthorized" 

85 return HTTPException( 

86 status_code=self.status_code, 

87 detail=detail, 

88 headers={"www-authenticate": challenge} if challenge else None, 

89 ) 

90 

91 

92class MCPOpenApiUpstreamError(Exception): 

93 """An OpenAPI-backed MCP tool's upstream answered with a non-2xx that is not a 401. 

94 

95 Carries the status only. The upstream's response body is deliberately dropped rather than served 

96 as tool content: it crosses a trust boundary and may hold prose, urls, or an error document that 

97 reads as data, which is how these failures came to be reported as successful tool output. This 

98 matches ``outcome_wire_value``'s contract for listing faults, category and status and nothing 

99 else. A 401 is raised as ``MCPUpstreamAuthError`` instead, so the caller learns to 

100 re-authenticate; every other status stays here, mirroring the regular MCP path where a 403 

101 deliberately does not produce a challenge. 

102 """ 

103 

104 def __init__(self, status_code: int, server_name: str) -> None: 

105 self.status_code = status_code 

106 self.server_name = server_name 

107 super().__init__(f"upstream returned HTTP {status_code}") 

108 

109 

110class MCPToolResultError(Exception): 

111 """An MCP tool call completed with ``isError=True`` in its result. 

112 

113 Never raised on the wire path: streamable HTTP MCP correctly returns tool 

114 failures as HTTP 200 with ``result.isError: true`` per the MCP spec. This 

115 exception only drives the standard failure logging (``status="failure"`` 

116 payload, OTel ERROR span) for such results. 

117 

118 Lives here rather than ``utils.py`` deliberately: tests reload ``utils`` 

119 to re-read its env-derived constants, and a reload would fork this class 

120 into two identities, breaking ``isinstance`` checks against instances 

121 created before the reload. 

122 """ 

123 

124 

125class MCPServerListError(Exception): 

126 """Carrier for a classified per-server listing fault (``faults.list_outcomes.ServerListFault``). 

127 

128 Raised where a server fetch used to silently return an empty tool list, so each boundary can 

129 apply its own policy: the aggregate listing absorbs it into that server's outcome, while 

130 single-server routes relay a truthful HTTP status instead of empty-success. The fault value is 

131 typed as ``object`` here only to avoid a circular import with the faults package; construction 

132 sites always pass a ``ServerListFault``. 

133 """ 

134 

135 def __init__(self, fault: object, server_name: str) -> None: 

136 self.fault = fault 

137 self.server_name = server_name 

138 super().__init__(f"Listing tools from MCP server {server_name!r} failed")