This is an automated email from the ASF dual-hosted git repository.
yufei pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/polaris-tools.git
The following commit(s) were added to refs/heads/main by this push:
new c3c1f5f MCP: Add configurable refresh buffer (#74)
c3c1f5f is described below
commit c3c1f5ffc563bbe49387239578a8857c44b3625b
Author: Yong Zheng <[email protected]>
AuthorDate: Mon Nov 24 19:06:55 2025 -0600
MCP: Add configurable refresh buffer (#74)
---
mcp-server/README.md | 17 +++++-----
mcp-server/polaris_mcp/authorization.py | 11 ++++--
mcp-server/polaris_mcp/server.py | 9 +++++
mcp-server/tests/test_authorization.py | 60 +++++++++++++++++++++++++++++++++
mcp-server/tests/test_server.py | 1 +
5 files changed, 87 insertions(+), 11 deletions(-)
diff --git a/mcp-server/README.md b/mcp-server/README.md
index a1c57c2..042b5e4 100644
--- a/mcp-server/README.md
+++ b/mcp-server/README.md
@@ -65,14 +65,15 @@ Please note: `--directory` specifies a local directory. It
is not needed when we
## Configuration
-| Variable | Description
| Default
|
-|----------------------------------------------------------------|----------------------------------------------------------|--------------------------------------------------|
-| `POLARIS_BASE_URL` | Base URL
for all Polaris REST calls. | `http://localhost:8181/`
|
-| `POLARIS_API_TOKEN` / `POLARIS_BEARER_TOKEN` / `POLARIS_TOKEN` | Static
bearer token (if supplied, overrides other auth). | _unset_
|
-| `POLARIS_CLIENT_ID` | OAuth
client id for client-credential flow. | _unset_
|
-| `POLARIS_CLIENT_SECRET` | OAuth
client secret. | _unset_
|
-| `POLARIS_TOKEN_SCOPE` | OAuth scope
string. | _unset_
|
-| `POLARIS_TOKEN_URL` | Optional
override for the token endpoint URL. |
`${POLARIS_BASE_URL}api/catalog/v1/oauth/tokens` |
+| Variable | Description
| Default
|
+|----------------------------------------------------------------|----------------------------------------------------------------|--------------------------------------------------|
+| `POLARIS_BASE_URL` | Base URL
for all Polaris REST calls. |
`http://localhost:8181/` |
+| `POLARIS_API_TOKEN` / `POLARIS_BEARER_TOKEN` / `POLARIS_TOKEN` | Static
bearer token (if supplied, overrides other auth). | _unset_
|
+| `POLARIS_CLIENT_ID` | OAuth
client id for client-credential flow. | _unset_
|
+| `POLARIS_CLIENT_SECRET` | OAuth
client secret. | _unset_
|
+| `POLARIS_TOKEN_SCOPE` | OAuth scope
string. | _unset_
|
+| `POLARIS_TOKEN_URL` | Optional
override for the token endpoint URL. |
`${POLARIS_BASE_URL}api/catalog/v1/oauth/tokens` |
+| `POLARIS_TOKEN_REFRESH_BUFFER_SECONDS` | Minimum
remaining token lifetime before refreshing in seconds. | `60.0`
|
When OAuth variables are supplied, the server automatically acquires and
refreshes tokens using the client credentials flow; otherwise a static bearer
token is used if provided.
diff --git a/mcp-server/polaris_mcp/authorization.py
b/mcp-server/polaris_mcp/authorization.py
index e58c8c8..0545ab4 100644
--- a/mcp-server/polaris_mcp/authorization.py
+++ b/mcp-server/polaris_mcp/authorization.py
@@ -59,6 +59,7 @@ class
ClientCredentialsAuthorizationProvider(AuthorizationProvider):
client_secret: str,
scope: Optional[str],
http: urllib3.PoolManager,
+ refresh_buffer_seconds: float,
) -> None:
self._token_endpoint = token_endpoint
self._client_id = client_id
@@ -67,6 +68,7 @@ class
ClientCredentialsAuthorizationProvider(AuthorizationProvider):
self._http = http
self._lock = threading.Lock()
self._cached: Optional[tuple[str, float]] = None # (token,
expires_at_epoch)
+ self._refresh_buffer_seconds = max(refresh_buffer_seconds, 0.0)
def authorization_header(self) -> Optional[str]:
token = self._current_token()
@@ -75,10 +77,13 @@ class
ClientCredentialsAuthorizationProvider(AuthorizationProvider):
def _current_token(self) -> Optional[str]:
now = time.time()
cached = self._cached
- if not cached or cached[1] - 60 <= now:
+ if not cached or cached[1] - self._refresh_buffer_seconds <= now:
with self._lock:
cached = self._cached
- if not cached or cached[1] - 60 <= time.time():
+ if (
+ not cached
+ or cached[1] - self._refresh_buffer_seconds <= time.time()
+ ):
self._cached = cached = self._fetch_token()
return cached[0] if cached else None
@@ -119,7 +124,7 @@ class
ClientCredentialsAuthorizationProvider(AuthorizationProvider):
ttl = float(expires_in)
except (TypeError, ValueError):
ttl = 3600.0
- ttl = max(ttl, 60.0)
+ ttl = max(ttl, self._refresh_buffer_seconds)
expires_at = time.time() + ttl
return token, expires_at
diff --git a/mcp-server/polaris_mcp/server.py b/mcp-server/polaris_mcp/server.py
index bdbacc1..c73c401 100644
--- a/mcp-server/polaris_mcp/server.py
+++ b/mcp-server/polaris_mcp/server.py
@@ -59,6 +59,7 @@ OUTPUT_SCHEMA = {
"required": ["isError"],
"additionalProperties": True,
}
+DEFAULT_TOKEN_REFRESH_BUFFER_SECONDS = 60.0
def create_server() -> FastMCP:
@@ -429,12 +430,20 @@ def _resolve_authorization_provider(
scope = _first_non_blank(os.getenv("POLARIS_TOKEN_SCOPE"))
token_url = _first_non_blank(os.getenv("POLARIS_TOKEN_URL"))
endpoint = token_url or urljoin(base_url,
"api/catalog/v1/oauth/tokens")
+ refresh_buffer_seconds = DEFAULT_TOKEN_REFRESH_BUFFER_SECONDS
+ refresh_buffer_seconds_str =
os.getenv("POLARIS_TOKEN_REFRESH_BUFFER_SECONDS")
+ if refresh_buffer_seconds_str:
+ try:
+ refresh_buffer_seconds =
float(refresh_buffer_seconds_str.strip())
+ except ValueError:
+ pass
return ClientCredentialsAuthorizationProvider(
token_endpoint=endpoint,
client_id=client_id,
client_secret=client_secret,
scope=scope,
http=http,
+ refresh_buffer_seconds=refresh_buffer_seconds,
)
return none()
diff --git a/mcp-server/tests/test_authorization.py
b/mcp-server/tests/test_authorization.py
index 4d57356..e120de6 100644
--- a/mcp-server/tests/test_authorization.py
+++ b/mcp-server/tests/test_authorization.py
@@ -65,6 +65,7 @@ def test_client_credentials_fetches_and_caches_tokens(
client_secret="secret",
scope=None,
http=http,
+ refresh_buffer_seconds=0.0,
)
with mock.patch("time.time", return_value=now):
@@ -94,6 +95,63 @@ def test_client_credentials_fetches_and_caches_tokens(
http.request.assert_called_once()
+def test_client_credentials_refresh_buffer() -> None:
+ http = mock.Mock()
+ now = time.time()
+ expires_in = 120
+ refresh_buffer = 30.0
+
+ response = SimpleNamespace(
+ status=200,
+ data=json.dumps({"access_token": "initial", "expires_in":
expires_in}).encode(
+ "utf-8"
+ ),
+ )
+ http.request.return_value = response
+
+ provider = ClientCredentialsAuthorizationProvider(
+ token_endpoint="https://auth/token",
+ client_id="client",
+ client_secret="secret",
+ scope=None,
+ http=http,
+ refresh_buffer_seconds=refresh_buffer,
+ )
+
+ # Initial valid token
+ with mock.patch("time.time", return_value=now):
+ header1 = provider.authorization_header()
+ assert header1 == "Bearer initial"
+ http.request.assert_called_once()
+ http.request.reset_mock()
+
+ # Valid token before refresh
+ with mock.patch("time.time", return_value=now + (expires_in -
refresh_buffer - 1)):
+ header2 = provider.authorization_header()
+ assert header2 == "Bearer initial"
+ http.request.assert_not_called()
+
+ # Refresh token once reached refresh buffer
+ refreshed_response = SimpleNamespace(
+ status=200,
+ data=json.dumps({"access_token": "refreshed", "expires_in":
expires_in}).encode(
+ "utf-8"
+ ),
+ )
+ http.request.return_value = refreshed_response
+ with mock.patch("time.time", return_value=now + (expires_in -
refresh_buffer)):
+ header3 = provider.authorization_header()
+ assert header3 == "Bearer refreshed"
+ http.request.assert_called_once()
+ http.request.reset_mock()
+
+ # Force expiry to trigger a refresh
+ with mock.patch("time.time", return_value=now + expires_in + 1):
+ header4 = provider.authorization_header()
+ assert header4 == "Bearer refreshed"
+ http.request.assert_not_called()
+
+
@pytest.mark.parametrize(
"payload,expected_message",
[
@@ -118,6 +176,7 @@ def test_client_credentials_rejects_invalid_responses(
client_secret="secret",
scope=None,
http=http,
+ refresh_buffer_seconds=0.0,
)
with pytest.raises(RuntimeError, match=expected_message):
@@ -134,6 +193,7 @@ def test_client_credentials_errors_on_non_200_status() ->
None:
client_secret="secret",
scope=None,
http=http,
+ refresh_buffer_seconds=0.0,
)
with pytest.raises(RuntimeError, match="500"):
diff --git a/mcp-server/tests/test_server.py b/mcp-server/tests/test_server.py
index 9680cc8..06e4f1d 100644
--- a/mcp-server/tests/test_server.py
+++ b/mcp-server/tests/test_server.py
@@ -247,4 +247,5 @@ class TestAuthorizationProviderResolution:
client_secret="secret",
scope="scope",
http=fake_http,
+ refresh_buffer_seconds=60.0,
)