This is an automated email from the ASF dual-hosted git repository.

yzheng 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 70b6353  [MCP] feat: add support for configurable HTTP timeout (#73)
70b6353 is described below

commit 70b6353941e92bd59c643a207fae21198d35520d
Author: Yong Zheng <[email protected]>
AuthorDate: Tue Nov 25 01:14:29 2025 -0600

    [MCP] feat: add support for configurable HTTP timeout (#73)
    
    * Added support for configurable HTTP timeout
    
    * MCP: Add unit tests for all tools and refactors (#51)
    
    * Added support for configurable HTTP timeout
    
    * Fix lint
    
    ---------
    
    Co-authored-by: Yufei Gu <[email protected]>
---
 mcp-server/README.md                    |  4 ++
 mcp-server/polaris_mcp/authorization.py |  4 +-
 mcp-server/polaris_mcp/rest.py          |  7 ++--
 mcp-server/polaris_mcp/server.py        | 34 +++++++++++++--
 mcp-server/tests/test_authorization.py  |  4 ++
 mcp-server/tests/test_rest_tool.py      |  7 ++--
 mcp-server/tests/test_server.py         | 74 ++++++++++++++++++++++++++++++++-
 7 files changed, 121 insertions(+), 13 deletions(-)

diff --git a/mcp-server/README.md b/mcp-server/README.md
index 042b5e4..2dbb1bd 100644
--- a/mcp-server/README.md
+++ b/mcp-server/README.md
@@ -74,6 +74,10 @@ Please note: `--directory` specifies a local directory. It 
is not needed when we
 | `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`                 
                          |
+| `POLARIS_HTTP_TIMEOUT_SECONDS`                                 | Default 
timeout in seconds for all HTTP requests.              | `30.0`                 
                          |
+| `POLARIS_HTTP_CONNECT_TIMEOUT_SECONDS`                         | Timeout in 
seconds for establishing HTTP connections.          | `30.0`                    
                       |
+| `POLARIS_HTTP_READ_TIMEOUT_SECONDS`                            | Timeout in 
seconds for reading HTTP responses.                 | `30.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 0545ab4..2b61894 100644
--- a/mcp-server/polaris_mcp/authorization.py
+++ b/mcp-server/polaris_mcp/authorization.py
@@ -60,12 +60,14 @@ class 
ClientCredentialsAuthorizationProvider(AuthorizationProvider):
         scope: Optional[str],
         http: urllib3.PoolManager,
         refresh_buffer_seconds: float,
+        timeout: urllib3.Timeout,
     ) -> None:
         self._token_endpoint = token_endpoint
         self._client_id = client_id
         self._client_secret = client_secret
         self._scope = scope
         self._http = http
+        self._timeout = timeout
         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)
@@ -102,7 +104,7 @@ class 
ClientCredentialsAuthorizationProvider(AuthorizationProvider):
             self._token_endpoint,
             body=encoded,
             headers={"Content-Type": "application/x-www-form-urlencoded"},
-            timeout=urllib3.Timeout(connect=20.0, read=20.0),
+            timeout=self._timeout,
         )
 
         if response.status != 200:
diff --git a/mcp-server/polaris_mcp/rest.py b/mcp-server/polaris_mcp/rest.py
index 530b8c6..8c9728d 100644
--- a/mcp-server/polaris_mcp/rest.py
+++ b/mcp-server/polaris_mcp/rest.py
@@ -31,9 +31,6 @@ from polaris_mcp.authorization import AuthorizationProvider, 
none
 from polaris_mcp.base import JSONDict, ToolExecutionResult
 
 
-DEFAULT_TIMEOUT = urllib3.Timeout(connect=30.0, read=30.0)
-
-
 def encode_path_segment(value: str) -> str:
     """URL-encode a string for safe use as an HTTP path component."""
 
@@ -150,6 +147,7 @@ class PolarisRestTool:
         base_url: str,
         default_path_prefix: str,
         http: urllib3.PoolManager,
+        timeout: urllib3.Timeout,
         authorization_provider: Optional[AuthorizationProvider] = None,
     ) -> None:
         self._name = name
@@ -158,6 +156,7 @@ class PolarisRestTool:
         self._path_prefix = _normalize_prefix(default_path_prefix)
         self._http = http
         self._authorization = authorization_provider or none()
+        self._timeout = timeout
 
     @property
     def name(self) -> str:
@@ -254,7 +253,7 @@ class PolarisRestTool:
             target_uri,
             body=body_text.encode("utf-8") if body_text is not None else None,
             headers=header_values,
-            timeout=DEFAULT_TIMEOUT,
+            timeout=self._timeout,
         )
 
         response_body = response.data.decode("utf-8") if response.data else ""
diff --git a/mcp-server/polaris_mcp/server.py b/mcp-server/polaris_mcp/server.py
index ea7a346..5e5ebdc 100644
--- a/mcp-server/polaris_mcp/server.py
+++ b/mcp-server/polaris_mcp/server.py
@@ -24,7 +24,7 @@ from __future__ import annotations
 import logging
 import logging.config
 import os
-from typing import Any, Mapping, MutableMapping, Sequence
+from typing import Any, Mapping, MutableMapping, Sequence, Optional
 from urllib.parse import urljoin, urlparse
 
 import urllib3
@@ -62,6 +62,7 @@ OUTPUT_SCHEMA = {
     "additionalProperties": True,
 }
 DEFAULT_TOKEN_REFRESH_BUFFER_SECONDS = 60.0
+DEFAULT_HTTP_TIMEOUT = 30.0
 LOGGING_CONFIG = {
     "version": 1,
     "disable_existing_loggers": False,
@@ -85,8 +86,9 @@ logger = logging.getLogger(__name__)
 def create_server() -> FastMCP:
     """Construct a FastMCP server with Polaris tools."""
     base_url = _resolve_base_url()
+    timeout = _resolve_http_timeout()
     http = urllib3.PoolManager()
-    authorization_provider = _resolve_authorization_provider(base_url, http)
+    authorization_provider = _resolve_authorization_provider(base_url, http, 
timeout)
     catalog_rest = PolarisRestTool(
         name="polaris.rest.catalog",
         description="Shared REST delegate for catalog operations",
@@ -94,6 +96,7 @@ def create_server() -> FastMCP:
         default_path_prefix="api/catalog/v1/",
         http=http,
         authorization_provider=authorization_provider,
+        timeout=timeout,
     )
     management_rest = PolarisRestTool(
         name="polaris.rest.management",
@@ -102,6 +105,7 @@ def create_server() -> FastMCP:
         default_path_prefix="api/management/v1/",
         http=http,
         authorization_provider=authorization_provider,
+        timeout=timeout,
     )
     policy_rest = PolarisRestTool(
         name="polaris.rest.policy",
@@ -110,6 +114,7 @@ def create_server() -> FastMCP:
         default_path_prefix="api/catalog/polaris/v1/",
         http=http,
         authorization_provider=authorization_provider,
+        timeout=timeout,
     )
 
     table_tool = PolarisTableTool(rest_client=catalog_rest)
@@ -434,8 +439,30 @@ def _validate_base_url(value: str) -> str:
     return value
 
 
+def _resolve_http_timeout() -> urllib3.Timeout:
+    def parse_timeout(raw: Optional[str]) -> Optional[float]:
+        try:
+            return float(raw.strip()) if raw and raw.strip() else None
+        except ValueError:
+            return None
+
+    default_timeout = parse_timeout(os.getenv("POLARIS_HTTP_TIMEOUT_SECONDS"))
+    connect_timeout = (
+        parse_timeout(os.getenv("POLARIS_HTTP_CONNECT_TIMEOUT_SECONDS"))
+        or default_timeout
+        or DEFAULT_HTTP_TIMEOUT
+    )
+    read_timeout = (
+        parse_timeout(os.getenv("POLARIS_HTTP_READ_TIMEOUT_SECONDS"))
+        or default_timeout
+        or DEFAULT_HTTP_TIMEOUT
+    )
+
+    return urllib3.Timeout(connect=connect_timeout, read=read_timeout)
+
+
 def _resolve_authorization_provider(
-    base_url: str, http: urllib3.PoolManager
+    base_url: str, http: urllib3.PoolManager, timeout: urllib3.Timeout
 ) -> AuthorizationProvider:
     token = _resolve_token()
     if token:
@@ -466,6 +493,7 @@ def _resolve_authorization_provider(
             scope=scope,
             http=http,
             refresh_buffer_seconds=refresh_buffer_seconds,
+            timeout=timeout,
         )
 
     return none()
diff --git a/mcp-server/tests/test_authorization.py 
b/mcp-server/tests/test_authorization.py
index e120de6..dfb0945 100644
--- a/mcp-server/tests/test_authorization.py
+++ b/mcp-server/tests/test_authorization.py
@@ -66,6 +66,7 @@ def test_client_credentials_fetches_and_caches_tokens(
         scope=None,
         http=http,
         refresh_buffer_seconds=0.0,
+        timeout=mock.sentinel.timeout,
     )
 
     with mock.patch("time.time", return_value=now):
@@ -116,6 +117,7 @@ def test_client_credentials_refresh_buffer() -> None:
         scope=None,
         http=http,
         refresh_buffer_seconds=refresh_buffer,
+        timeout=mock.sentinel.timeout,
     )
 
     # Initial valid token
@@ -177,6 +179,7 @@ def test_client_credentials_rejects_invalid_responses(
         scope=None,
         http=http,
         refresh_buffer_seconds=0.0,
+        timeout=mock.sentinel.timeout,
     )
 
     with pytest.raises(RuntimeError, match=expected_message):
@@ -194,6 +197,7 @@ def test_client_credentials_errors_on_non_200_status() -> 
None:
         scope=None,
         http=http,
         refresh_buffer_seconds=0.0,
+        timeout=mock.sentinel.timeout,
     )
 
     with pytest.raises(RuntimeError, match="500"):
diff --git a/mcp-server/tests/test_rest_tool.py 
b/mcp-server/tests/test_rest_tool.py
index ba108ea..cbd3682 100644
--- a/mcp-server/tests/test_rest_tool.py
+++ b/mcp-server/tests/test_rest_tool.py
@@ -27,7 +27,7 @@ from unittest import mock
 import pytest
 from urllib3._collections import HTTPHeaderDict
 
-from polaris_mcp.rest import DEFAULT_TIMEOUT, PolarisRestTool
+from polaris_mcp.rest import PolarisRestTool
 
 
 def _build_response(
@@ -59,6 +59,7 @@ def _create_tool() -> tuple[PolarisRestTool, mock.Mock, 
mock.Mock]:
         default_path_prefix="api/catalog/v1/",
         http=http,
         authorization_provider=auth,
+        timeout=mock.sentinel.timeout,
     )
     return tool, http, auth
 
@@ -97,7 +98,7 @@ def test_call_builds_request_and_metadata_with_json_body() -> 
None:
         expected_url,
         body=b'{"name": "analytics"}',
         headers=expected_headers,
-        timeout=DEFAULT_TIMEOUT,
+        timeout=mock.sentinel.timeout,
     )
     auth.authorization_header.assert_not_called()
 
@@ -148,7 +149,7 @@ def 
test_call_uses_authorization_provider_and_handles_plain_text() -> None:
         expected_url,
         body=b"payload",
         headers=expected_headers,
-        timeout=DEFAULT_TIMEOUT,
+        timeout=mock.sentinel.timeout,
     )
     auth.authorization_header.assert_called_once()
 
diff --git a/mcp-server/tests/test_server.py b/mcp-server/tests/test_server.py
index 06e4f1d..906908c 100644
--- a/mcp-server/tests/test_server.py
+++ b/mcp-server/tests/test_server.py
@@ -210,7 +210,7 @@ class TestAuthorizationProviderResolution:
             mock.patch.dict(os.environ, {}, clear=True),
         ):
             provider = server._resolve_authorization_provider(
-                "https://base/";, fake_http
+                "https://base/";, fake_http, mock.sentinel.timeout
             )
 
         assert isinstance(provider, server.StaticAuthorizationProvider)
@@ -237,7 +237,7 @@ class TestAuthorizationProviderResolution:
             ) as mock_factory,
         ):
             provider = server._resolve_authorization_provider(
-                "https://base/";, fake_http
+                "https://base/";, fake_http, mock.sentinel.timeout
             )
 
         assert provider is fake_provider
@@ -248,4 +248,74 @@ class TestAuthorizationProviderResolution:
             scope="scope",
             http=fake_http,
             refresh_buffer_seconds=60.0,
+            timeout=mock.sentinel.timeout,
         )
+
+
+class TestServerConfiguration:
+    def test_resolve_http_timeout_defaults(self) -> None:
+        with mock.patch.dict(os.environ, {}, clear=True):
+            timeout = server._resolve_http_timeout()
+            assert timeout.connect_timeout == server.DEFAULT_HTTP_TIMEOUT
+            assert timeout.read_timeout == server.DEFAULT_HTTP_TIMEOUT
+
+    def test_resolve_http_timeout_global(self) -> None:
+        with mock.patch.dict(
+            os.environ, {"POLARIS_HTTP_TIMEOUT_SECONDS": "60.0"}, clear=True
+        ):
+            timeout = server._resolve_http_timeout()
+            assert timeout.connect_timeout == 60.0
+            assert timeout.read_timeout == 60.0
+
+    def test_resolve_http_timeout_specific_overrides(self) -> None:
+        with mock.patch.dict(
+            os.environ,
+            {
+                "POLARIS_HTTP_TIMEOUT_SECONDS": "60.0",
+                "POLARIS_HTTP_CONNECT_TIMEOUT_SECONDS": "10.0",
+                "POLARIS_HTTP_READ_TIMEOUT_SECONDS": "120.0",
+            },
+            clear=True,
+        ):
+            timeout = server._resolve_http_timeout()
+            assert timeout.connect_timeout == 10.0
+            assert timeout.read_timeout == 120.0
+
+    def test_resolve_http_timeout_partial_overrides(self) -> None:
+        with mock.patch.dict(
+            os.environ,
+            {
+                "POLARIS_HTTP_TIMEOUT_SECONDS": "60.0",
+                "POLARIS_HTTP_CONNECT_TIMEOUT_SECONDS": "10.0",
+            },
+            clear=True,
+        ):
+            timeout = server._resolve_http_timeout()
+            assert timeout.connect_timeout == 10.0
+            assert timeout.read_timeout == 60.0
+
+        with mock.patch.dict(
+            os.environ,
+            {
+                "POLARIS_HTTP_TIMEOUT_SECONDS": "60.0",
+                "POLARIS_HTTP_READ_TIMEOUT_SECONDS": "120.0",
+            },
+            clear=True,
+        ):
+            timeout = server._resolve_http_timeout()
+            assert timeout.connect_timeout == 60.0
+            assert timeout.read_timeout == 120.0
+
+    def test_resolve_http_timeout_invalid_values(self) -> None:
+        with mock.patch.dict(
+            os.environ,
+            {
+                "POLARIS_HTTP_TIMEOUT_SECONDS": "invalid",
+                "POLARIS_HTTP_CONNECT_TIMEOUT_SECONDS": "bad",
+                "POLARIS_HTTP_READ_TIMEOUT_SECONDS": "N/A",
+            },
+            clear=True,
+        ):
+            timeout = server._resolve_http_timeout()
+            assert timeout.connect_timeout == server.DEFAULT_HTTP_TIMEOUT
+            assert timeout.read_timeout == server.DEFAULT_HTTP_TIMEOUT

Reply via email to