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
« 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.
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).
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.
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.
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.
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.
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"""
39from typing import Final, Protocol
40from urllib.parse import urlparse
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
46# The actor the removed discovery write-back stamped rows with.
47_DISCOVERY_ACTOR: Final = "mcp_oauth_discovery"
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"
53_AUTH_TYPES_WITH_ISSUER_ANCHORING: Final = ("oauth2", "true_passthrough", "oauth_delegate")
56def _origin(url: str) -> str | None:
57 """The scheme-and-authority identity of ``url``, or ``None`` when it has none.
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}"
68class _MCPServerRow(Protocol):
69 """The MCP server row fields this heal reads, so the untyped DB record is narrowed once here."""
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
82def _is_stamped_issuer_row(row: _MCPServerRow) -> bool:
83 """Whether this row carries the full signature of a gateway-written issuer stamp.
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)
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
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 )
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