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
5 changes: 4 additions & 1 deletion src/mcp/client/auth/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,10 @@ def is_token_valid(self) -> bool:
return bool(
self.current_tokens
and self.current_tokens.access_token
and (not self.token_expiry_time or time.time() <= self.token_expiry_time)
# None means the authorization server declared no expiry. Compare against that
# explicitly: a falsy test would also treat an expiry of 0.0 (the epoch, long
# expired) as "no expiry" and keep presenting a dead token.
and (self.token_expiry_time is None or time.time() <= self.token_expiry_time)
)

def can_refresh_token(self) -> bool:
Expand Down
10 changes: 6 additions & 4 deletions src/mcp/shared/auth_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,16 +65,18 @@ def check_resource_allowed(requested_resource: str, configured_resource: str) ->
return requested_path.startswith(configured_path)


def calculate_token_expiry(expires_in: int | str | None) -> float | None:
def calculate_token_expiry(expires_in: int | float | str | None) -> float | None:
"""Calculate token expiry timestamp from expires_in seconds.

Args:
expires_in: Seconds until token expiration (may be string from some servers)
expires_in: Seconds until token expiration (may be a string or a float from some servers)

Returns:
Unix timestamp when token expires, or None if no expiry specified
"""
if expires_in is None:
return None # pragma: no cover
# Defensive: handle servers that return expires_in as string
return time.time() + int(expires_in)
# Defensive: handle servers that return expires_in as a string, including ones that
# send a fractional value such as "3599.5". int() would raise ValueError on those and
# silently truncate the float case, expiring the token up to a second early.
return time.time() + float(expires_in)
13 changes: 13 additions & 0 deletions tests/client/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,19 @@ async def test_token_validity_checking(self, oauth_provider: OAuthClientProvider
context.client_info = None
assert not context.can_refresh_token()

@pytest.mark.anyio
async def test_token_expiry_of_zero_is_expired(self, oauth_provider: OAuthClientProvider, valid_tokens: OAuthToken):
"""An expiry timestamp of 0 means expired, not 'no expiry'.

`token_expiry_time` is a float|None where None means the server declared no
expiry. Testing it for truthiness conflates 0.0 (the epoch, long expired) with
absence, so a token whose expiry resolves to 0 is treated as never expiring.
"""
context = oauth_provider.context
context.current_tokens = valid_tokens
context.token_expiry_time = 0.0
assert not context.is_token_valid()

def test_clear_tokens(self, oauth_provider: OAuthClientProvider, valid_tokens: OAuthToken):
"""Test clear_tokens() removes token data."""
context = oauth_provider.context
Expand Down
53 changes: 52 additions & 1 deletion tests/shared/test_auth_utils.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"""Tests for OAuth 2.0 Resource Indicators utilities."""

import time

from pydantic import HttpUrl

from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url
from mcp.shared.auth_utils import calculate_token_expiry, check_resource_allowed, resource_url_from_server_url

# Tests for resource_url_from_server_url function

Expand Down Expand Up @@ -121,3 +123,52 @@ def test_check_resource_allowed_empty_paths():
assert check_resource_allowed("https://example.com", "https://example.com") is True
assert check_resource_allowed("https://example.com/", "https://example.com") is True
assert check_resource_allowed("https://example.com/api", "https://example.com") is True


# Tests for calculate_token_expiry function


def test_calculate_token_expiry_none_returns_none():
"""A server that declares no expiry yields None, not a timestamp."""
assert calculate_token_expiry(None) is None


def test_calculate_token_expiry_integer_seconds():
"""The common case: expires_in arrives as an integer."""
before = time.time()
expiry = calculate_token_expiry(3600)
after = time.time()
assert expiry is not None
assert before + 3600 <= expiry <= after + 3600


def test_calculate_token_expiry_numeric_string():
"""Some servers emit expires_in as a JSON string."""
before = time.time()
expiry = calculate_token_expiry("3600")
after = time.time()
assert expiry is not None
assert before + 3600 <= expiry <= after + 3600


def test_calculate_token_expiry_fractional_string():
"""A fractional string must round, not raise.

The declared signature accepts str, and the docstring promises the string case is
handled defensively, but int("3599.5") raises ValueError. Servers that send a
fractional expires_in as a string fail token setup instead of expiring 0.5s later.
"""
before = time.time()
expiry = calculate_token_expiry("3599.5")
after = time.time()
assert expiry is not None
assert before + 3599.5 <= expiry <= after + 3599.5


def test_calculate_token_expiry_float():
"""A float expires_in keeps its fraction instead of being truncated."""
before = time.time()
expiry = calculate_token_expiry(3599.5)
after = time.time()
assert expiry is not None
assert before + 3599.5 <= expiry <= after + 3599.5
Loading