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

52 statements  

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

1"""One-time heal for MCP server rows whose ``issuer`` a released version wrote by itself. 

2 

3Until the write was removed, OAuth discovery stamped the issuer it discovered onto the ``issuer`` 

4column trust-on-first-use. That column means "the admin pinned this trust anchor", so the next 

5registry build read the gateway's own output back as admin intent: the server turned issuer-anchored 

6(RFC 8414 section 3.3), its stored authorization/token/registration URLs stopped applying, and a 

7failed issuer-document fetch left it with no authorize endpoint (GH #34985). 

8 

9Deleting the write fixes every row created afterwards but cannot fix a row already stamped, which 

10still reads as pinned. This heals those rows by clearing the stamp so their configured endpoints 

11apply again. 

12 

13The signal is a heuristic, and deliberately a narrow one. ``updated_by`` records only the most recent 

14writer, and no audit trail says which field that writer touched, so "discovery wrote this issuer" is 

15not directly knowable. Two independent clauses bound it, and each rules out a different way of 

16destroying a pin an admin meant. 

17 

18Configured endpoints must be present. A deliberately pinned row very often has none, both because the 

19Issuer field is documented as overriding them and because ``update_mcp_server`` clears them when an 

20issuer changes, so "issuer set, endpoints empty" is the canonical shape of a real pin and must never 

21be cleared on this evidence. Skipping those rows costs little: with nothing configured to restore, the 

22anchored and resource-rooted paths resolve from the same upstream document, and the row still gets the 

23unresolved-endpoint retry and the anchored-discard warning. 

24 

25The configured endpoints must also share the issuer's origin. A stamped issuer is by construction the 

26one self-attested by the authorization-server document discovery reached from this very server, so 

27endpoints typed alongside it address that same authority. An admin who pinned an issuer and typed 

28endpoints for a different authority is expressing an intent that clearing the issuer would discard, so 

29that row is warned about and never healed. 

30 

31What survives both clauses is a row whose configured endpoints and stamped issuer share an origin, 

32which is exactly the GH #34985 shape. An admin who pinned that same origin by hand lands here too, and 

33for them the clear is close to a no-op: their typed endpoints keep serving and still anchor the 

34RFC 9700 corroboration gate, with only the stricter section 3.3 anchoring lost. Every heal logs the 

35cleared value so it can be restored, and the clear is recorded under this module's actor so the heal 

36runs at most once per row. 

37""" 

38 

39from typing import Final, Protocol 

40from urllib.parse import urlparse 

41 

42from litellm._logging import verbose_proxy_logger 

43from litellm.proxy._experimental.mcp_server.oauth_utils import canonicalize_url_identity 

44from litellm.proxy.utils import PrismaClient 

45 

46# The actor the removed discovery write-back stamped rows with. 

47_DISCOVERY_ACTOR: Final = "mcp_oauth_discovery" 

48 

49# The actor recorded on a healed row, which also makes the heal idempotent: once a row is cleared it 

50# no longer matches ``updated_by == _DISCOVERY_ACTOR`` and is never reconsidered. 

51_BACKFILL_ACTOR: Final = "mcp_oauth_issuer_stamp_backfill" 

52 

53_AUTH_TYPES_WITH_ISSUER_ANCHORING: Final = ("oauth2", "true_passthrough", "oauth_delegate") 

54 

55 

56def _origin(url: str) -> str | None: 

57 """The scheme-and-authority identity of ``url``, or ``None`` when it has none. 

58 

59 Built on the shared URL canonicalizer so the lowercase-host and default-port rules match the 

60 RFC 8414 issuer comparison the resolution path uses, instead of being re-derived here. 

61 """ 

62 parsed: Final = urlparse(canonicalize_url_identity(url)) 

63 if not parsed.scheme or not parsed.netloc: 

64 return None 

65 return f"{parsed.scheme}://{parsed.netloc}" 

66 

67 

68class _MCPServerRow(Protocol): 

69 """The MCP server row fields this heal reads, so the untyped DB record is narrowed once here.""" 

70 

71 server_id: str 

72 alias: str | None 

73 server_name: str | None 

74 auth_type: str | None 

75 issuer: str | None 

76 authorization_url: str | None 

77 token_url: str | None 

78 registration_url: str | None 

79 updated_by: str | None 

80 

81 

82def _is_stamped_issuer_row(row: _MCPServerRow) -> bool: 

83 """Whether this row carries the full signature of a gateway-written issuer stamp. 

84 

85 The whole rule lives here, including the writer check the query also filters on, so the decision 

86 to clear an admin-visible field is auditable in one place rather than split between a predicate 

87 and a query. 

88 """ 

89 if getattr(row, "updated_by", None) != _DISCOVERY_ACTOR: 

90 return False 

91 if not (getattr(row, "issuer", None) or "").strip(): 

92 return False 

93 if getattr(row, "auth_type", None) not in _AUTH_TYPES_WITH_ISSUER_ANCHORING: 

94 return False 

95 configured: Final = tuple( 

96 value.strip() 

97 for value in (row.authorization_url, row.token_url, row.registration_url) 

98 if value and value.strip() 

99 ) 

100 if not configured: 

101 return False 

102 issuer_origin: Final = _origin(row.issuer or "") 

103 return issuer_origin is not None and all(_origin(endpoint) == issuer_origin for endpoint in configured) 

104 

105 

106async def backfill_discovery_stamped_issuers(prisma_client: PrismaClient) -> int: 

107 """Clear gateway-written issuer stamps, returning the number of rows healed.""" 

108 candidate_rows: Final[list[_MCPServerRow]] = await prisma_client.db.litellm_mcpservertable.find_many( 

109 where={ 

110 "updated_by": _DISCOVERY_ACTOR, 

111 "auth_type": {"in": list(_AUTH_TYPES_WITH_ISSUER_ANCHORING)}, 

112 }, 

113 ) 

114 stamped: Final = tuple(row for row in candidate_rows if _is_stamped_issuer_row(row)) 

115 if not stamped: 115 ↛ 118line 115 didn't jump to line 118 because the condition on line 115 was always true

116 return 0 

117 

118 healed = 0 

119 for row in stamped: 

120 try: 

121 await prisma_client.db.litellm_mcpservertable.update( 

122 where={"server_id": row.server_id}, 

123 data={"issuer": None, "updated_by": _BACKFILL_ACTOR}, 

124 ) 

125 except Exception as exc: # noqa: BLE001 - per-row best effort; the next boot retries 

126 verbose_proxy_logger.warning( 

127 "MCP issuer stamp backfill: could not heal server_id=%s: %s", row.server_id, exc 

128 ) 

129 continue 

130 healed += 1 

131 verbose_proxy_logger.warning( 

132 "MCP issuer stamp backfill: cleared issuer %r on server_id=%s (alias=%s). OAuth discovery " 

133 "had written that value onto the Issuer column, which made the server issuer-anchored and " 

134 "fail-closed, and its configured Authorization/Token/Registration URLs were being ignored " 

135 "as a result; those now apply again. If you pinned this issuer deliberately, set it again " 

136 "via the dashboard or PUT /v1/mcp/server to restore RFC 8414 section 3.3 anchoring.", 

137 row.issuer, 

138 row.server_id, 

139 row.alias or row.server_name, 

140 ) 

141 

142 if healed: 

143 verbose_proxy_logger.warning( 

144 "MCP issuer stamp backfill: healed %d server(s) whose Issuer had been written by OAuth " 

145 "discovery rather than by an admin", 

146 healed, 

147 ) 

148 return healed