diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index b8d10335182..8d1713a5911 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -742,19 +742,24 @@ envelope caps it, the by-design behaviour for an upstream that omits the field." def _classify_upstream_lifetime(raw_expires_in: object) -> "int | Literal['unspecified', 'expired']": """Classify an upstream ``expires_in`` into a positive number of seconds, ``"unspecified"`` (absent - or unparseable, so the envelope caps it), or ``"expired"`` (a parseable non-positive value the - upstream reports as already elapsed). Telling "we do not know the lifetime" apart from "the upstream - says it is already dead" is what stops an explicitly-expired token from silently receiving the - envelope's 1h cap. ``bool`` is excluded (an ``int`` subclass but never a real lifetime), and - ``int(float(...))`` can raise on ``NaN`` / ``Infinity`` / oversized input, which reads as - unparseable rather than surfacing as a 500.""" + or unparseable, so the envelope caps it), or ``"expired"`` (a non-positive value the upstream reports + as already elapsed). Telling "we do not know the lifetime" apart from "the upstream says it is + already dead" is what stops an explicitly-expired token from silently receiving the envelope's 1h + cap. The expired decision is made on the parsed numeric value, not on ``int(...)`` of it, so a + positive sub-second lifetime in ``(0, 1)`` is not truncated to ``0`` and misread as elapsed; the + envelope works in whole seconds, so such a lifetime clamps up to its 1s floor. ``bool`` is excluded + (an ``int`` subclass but never a real lifetime), and the conversions can raise on ``NaN`` / + ``Infinity`` / oversized input, which reads as unparseable rather than surfacing as a 500.""" if raw_expires_in is None or isinstance(raw_expires_in, bool) or not isinstance(raw_expires_in, (int, float, str)): return "unspecified" try: - seconds = int(float(raw_expires_in)) + numeric = float(raw_expires_in) + seconds = int(numeric) except (ValueError, TypeError, OverflowError): return "unspecified" - return seconds if seconds > 0 else "expired" + if numeric <= 0: + return "expired" + return max(1, seconds) def _bridge_grant_from_token_response(token_response: object) -> "UpstreamTokenGrant | _UpstreamGrantRejection": diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index f611d2f6a24..68466e624ec 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -4661,6 +4661,22 @@ async def test_bridge_mint_upstream_expired_lifetime_is_502(): assert json.loads(response.body)["error"] == "server_error" +@pytest.mark.asyncio +async def test_bridge_mint_positive_sub_second_lifetime_mints_not_502(): + """A positive fractional expires_in in (0, 1) is a live token, not an elapsed one, so it mints a + (1s-floored) envelope rather than being truncated to 0 and rejected with 502 after the single-use + code was already consumed. Regression for classifying a sub-second remaining lifetime as expired.""" + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.oauth_delegate) + upstream = {"access_token": "UP", "token_type": "Bearer", "expires_in": 0.5} + response = await _exchange_for_bridge_server(server, upstream, key_hash="hashed-litellm-key-77") + assert response.status_code == 200 + body = json.loads(response.body) + assert body["access_token"].startswith("llm_env_") + assert body["expires_in"] >= 0 + + @pytest.mark.asyncio async def test_bridge_mint_unknown_lifetime_is_capped_not_rejected(): """An absent or unparseable expires_in leaves the lifetime unknown, which the envelope caps (never @@ -4742,6 +4758,13 @@ def test_classify_upstream_lifetime(): # explicit, parseable, non-positive -> the upstream says the token is already dead assert _classify_upstream_lifetime(0) == "expired" assert _classify_upstream_lifetime(-5) == "expired" + assert _classify_upstream_lifetime(-0.5) == "expired" + # a positive sub-second lifetime is alive, not elapsed; it clamps up to the envelope's 1s floor + # rather than truncating to 0 and being misread as expired + assert _classify_upstream_lifetime(0.5) == 1 + assert _classify_upstream_lifetime(0.001) == 1 + # a positive value >= 1 truncates toward zero (never overstating the stated lifetime) + assert _classify_upstream_lifetime(1.9) == 1 # unknown lifetime -> cap (never invent a longer life than the upstream stated) assert _classify_upstream_lifetime(None) == "unspecified" assert _classify_upstream_lifetime(True) == "unspecified"