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 34fa4e7  MCP: Add unit tests for all tools and refactors (#51)
34fa4e7 is described below

commit 34fa4e7d325f31079b92a055bcface81397e443b
Author: Yufei Gu <[email protected]>
AuthorDate: Sun Nov 23 10:15:55 2025 -0800

    MCP: Add unit tests for all tools and refactors (#51)
---
 mcp-server/polaris_mcp/rest.py                 |   4 +-
 mcp-server/polaris_mcp/server.py               |  47 ++++++---
 mcp-server/polaris_mcp/tools/catalog.py        |  21 +---
 mcp-server/polaris_mcp/tools/catalog_role.py   |  20 +---
 mcp-server/polaris_mcp/tools/namespace.py      |  21 +---
 mcp-server/polaris_mcp/tools/policy.py         |  21 +---
 mcp-server/polaris_mcp/tools/principal.py      |  21 +---
 mcp-server/polaris_mcp/tools/principal_role.py |  21 +---
 mcp-server/polaris_mcp/tools/table.py          |  21 +---
 mcp-server/tests/test_authorization.py         | 140 +++++++++++++++++++++++++
 mcp-server/tests/test_catalog_role_tool.py     |  73 +++++++++++++
 mcp-server/tests/test_catalog_tool.py          |  76 ++++++++++++++
 mcp-server/tests/test_namespace_tool.py        |  46 ++++----
 mcp-server/tests/test_policy_tool.py           |  59 +++++++++++
 mcp-server/tests/test_principal_role_tool.py   |  62 +++++++++++
 mcp-server/tests/test_principal_tool.py        |  64 +++++++++++
 mcp-server/tests/test_server.py                |  19 ++++
 mcp-server/tests/test_table_tool.py            |  77 +++++++-------
 mcp-server/uv.lock                             |  82 ++++++++++++++-
 19 files changed, 702 insertions(+), 193 deletions(-)

diff --git a/mcp-server/polaris_mcp/rest.py b/mcp-server/polaris_mcp/rest.py
index e762840..530b8c6 100644
--- a/mcp-server/polaris_mcp/rest.py
+++ b/mcp-server/polaris_mcp/rest.py
@@ -27,8 +27,8 @@ from urllib.parse import urlencode, urljoin, urlsplit, 
urlunsplit, quote
 
 import urllib3
 
-from .authorization import AuthorizationProvider, none
-from .base import JSONDict, ToolExecutionResult
+from polaris_mcp.authorization import AuthorizationProvider, none
+from polaris_mcp.base import JSONDict, ToolExecutionResult
 
 
 DEFAULT_TIMEOUT = urllib3.Timeout(connect=30.0, read=30.0)
diff --git a/mcp-server/polaris_mcp/server.py b/mcp-server/polaris_mcp/server.py
index 600806b..bdbacc1 100644
--- a/mcp-server/polaris_mcp/server.py
+++ b/mcp-server/polaris_mcp/server.py
@@ -31,14 +31,15 @@ from fastmcp.tools.tool import ToolResult as 
FastMcpToolResult
 from importlib import metadata
 from mcp.types import TextContent
 
-from .authorization import (
+from polaris_mcp.authorization import (
     AuthorizationProvider,
     ClientCredentialsAuthorizationProvider,
     StaticAuthorizationProvider,
     none,
 )
-from .base import ToolExecutionResult
-from .tools import (
+from polaris_mcp.base import ToolExecutionResult
+from polaris_mcp.rest import PolarisRestTool
+from polaris_mcp.tools import (
     PolarisCatalogRoleTool,
     PolarisCatalogTool,
     PolarisNamespaceTool,
@@ -66,16 +67,38 @@ def create_server() -> FastMCP:
     base_url = _resolve_base_url()
     http = urllib3.PoolManager()
     authorization_provider = _resolve_authorization_provider(base_url, http)
-
-    table_tool = PolarisTableTool(base_url, http, authorization_provider)
-    namespace_tool = PolarisNamespaceTool(base_url, http, 
authorization_provider)
-    principal_tool = PolarisPrincipalTool(base_url, http, 
authorization_provider)
-    principal_role_tool = PolarisPrincipalRoleTool(
-        base_url, http, authorization_provider
+    catalog_rest = PolarisRestTool(
+        name="polaris.rest.catalog",
+        description="Shared REST delegate for catalog operations",
+        base_url=base_url,
+        default_path_prefix="api/catalog/v1/",
+        http=http,
+        authorization_provider=authorization_provider,
+    )
+    management_rest = PolarisRestTool(
+        name="polaris.rest.management",
+        description="Shared REST delegate for management operations",
+        base_url=base_url,
+        default_path_prefix="api/management/v1/",
+        http=http,
+        authorization_provider=authorization_provider,
     )
-    catalog_role_tool = PolarisCatalogRoleTool(base_url, http, 
authorization_provider)
-    policy_tool = PolarisPolicyTool(base_url, http, authorization_provider)
-    catalog_tool = PolarisCatalogTool(base_url, http, authorization_provider)
+    policy_rest = PolarisRestTool(
+        name="polaris.rest.policy",
+        description="Shared REST delegate for policy operations",
+        base_url=base_url,
+        default_path_prefix="api/catalog/polaris/v1/",
+        http=http,
+        authorization_provider=authorization_provider,
+    )
+
+    table_tool = PolarisTableTool(rest_client=catalog_rest)
+    namespace_tool = PolarisNamespaceTool(rest_client=catalog_rest)
+    principal_tool = PolarisPrincipalTool(rest_client=management_rest)
+    principal_role_tool = PolarisPrincipalRoleTool(rest_client=management_rest)
+    catalog_role_tool = PolarisCatalogRoleTool(rest_client=management_rest)
+    policy_tool = PolarisPolicyTool(rest_client=policy_rest)
+    catalog_tool = PolarisCatalogTool(rest_client=management_rest)
 
     server_version = _resolve_package_version()
     mcp = FastMCP(
diff --git a/mcp-server/polaris_mcp/tools/catalog.py 
b/mcp-server/polaris_mcp/tools/catalog.py
index 0f7280a..cdbd909 100644
--- a/mcp-server/polaris_mcp/tools/catalog.py
+++ b/mcp-server/polaris_mcp/tools/catalog.py
@@ -23,9 +23,6 @@ from __future__ import annotations
 import copy
 from typing import Any, Optional, Set
 
-import urllib3
-
-from polaris_mcp.authorization import AuthorizationProvider
 from polaris_mcp.base import (
     JSONDict,
     McpTool,
@@ -50,20 +47,8 @@ class PolarisCatalogTool(McpTool):
     UPDATE_ALIASES: Set[str] = {"update"}
     DELETE_ALIASES: Set[str] = {"delete", "drop", "remove"}
 
-    def __init__(
-        self,
-        base_url: str,
-        http: urllib3.PoolManager,
-        authorization_provider: AuthorizationProvider,
-    ) -> None:
-        self._delegate = PolarisRestTool(
-            name="polaris.catalog.delegate",
-            description="Internal delegate for catalog operations",
-            base_url=base_url,
-            default_path_prefix="api/management/v1/",
-            http=http,
-            authorization_provider=authorization_provider,
-        )
+    def __init__(self, rest_client: PolarisRestTool) -> None:
+        self._rest_client = rest_client
 
     @property
     def name(self) -> str:
@@ -154,7 +139,7 @@ class PolarisCatalogTool(McpTool):
         else:  # pragma: no cover
             raise ValueError(f"Unsupported operation: {operation}")
 
-        raw = self._delegate.call(delegate_args)
+        raw = self._rest_client.call(delegate_args)
         return self._maybe_augment_error(raw, normalized)
 
     def _maybe_augment_error(
diff --git a/mcp-server/polaris_mcp/tools/catalog_role.py 
b/mcp-server/polaris_mcp/tools/catalog_role.py
index 2ec9064..eeb0111 100644
--- a/mcp-server/polaris_mcp/tools/catalog_role.py
+++ b/mcp-server/polaris_mcp/tools/catalog_role.py
@@ -23,9 +23,7 @@ from __future__ import annotations
 
 import copy
 from typing import Any, Dict, Optional, Set
-import urllib3
 
-from polaris_mcp.authorization import AuthorizationProvider
 from polaris_mcp.base import (
     JSONDict,
     McpTool,
@@ -55,20 +53,8 @@ class PolarisCatalogRoleTool(McpTool):
     ADD_GRANT_ALIASES: Set[str] = {"add-grant", "grant"}
     REVOKE_GRANT_ALIASES: Set[str] = {"revoke-grant"}
 
-    def __init__(
-        self,
-        base_url: str,
-        http: urllib3.PoolManager,
-        authorization_provider: AuthorizationProvider,
-    ) -> None:
-        self._delegate = PolarisRestTool(
-            name="polaris.catalogrole.delegate",
-            description="Internal delegate for catalog role operations",
-            base_url=base_url,
-            default_path_prefix="api/management/v1/",
-            http=http,
-            authorization_provider=authorization_provider,
-        )
+    def __init__(self, rest_client: PolarisRestTool) -> None:
+        self._rest_client = rest_client
 
     @property
     def name(self) -> str:
@@ -192,7 +178,7 @@ class PolarisCatalogRoleTool(McpTool):
         else:  # pragma: no cover
             raise ValueError(f"Unsupported operation: {operation}")
 
-        raw = self._delegate.call(delegate_args)
+        raw = self._rest_client.call(delegate_args)
         return self._maybe_augment_error(raw, normalized)
 
     def _catalog_role_path(self, base_path: str, arguments: Dict[str, Any]) -> 
str:
diff --git a/mcp-server/polaris_mcp/tools/namespace.py 
b/mcp-server/polaris_mcp/tools/namespace.py
index e1fbcf5..02f312d 100644
--- a/mcp-server/polaris_mcp/tools/namespace.py
+++ b/mcp-server/polaris_mcp/tools/namespace.py
@@ -25,9 +25,6 @@ import copy
 import string
 from typing import Any, Dict, List, Optional, Set
 
-import urllib3
-
-from polaris_mcp.authorization import AuthorizationProvider
 from polaris_mcp.base import (
     JSONDict,
     McpTool,
@@ -57,20 +54,8 @@ class PolarisNamespaceTool(McpTool):
     GET_PROPS_ALIASES: Set[str] = {"get-properties", "properties"}
     DELETE_ALIASES: Set[str] = {"delete", "drop", "remove"}
 
-    def __init__(
-        self,
-        base_url: str,
-        http: urllib3.PoolManager,
-        authorization_provider: AuthorizationProvider,
-    ) -> None:
-        self._delegate = PolarisRestTool(
-            name="polaris.namespace.delegate",
-            description="Internal delegate for namespace operations",
-            base_url=base_url,
-            default_path_prefix="api/catalog/v1/",
-            http=http,
-            authorization_provider=authorization_provider,
-        )
+    def __init__(self, rest_client: PolarisRestTool) -> None:
+        self._rest_client = rest_client
 
     @property
     def name(self) -> str:
@@ -164,7 +149,7 @@ class PolarisNamespaceTool(McpTool):
         else:  # pragma: no cover - normalize guarantees cases
             raise ValueError(f"Unsupported operation: {operation}")
 
-        raw = self._delegate.call(delegate_args)
+        raw = self._rest_client.call(delegate_args)
         return self._maybe_augment_error(raw, normalized)
 
     def _handle_list(self, delegate_args: JSONDict, catalog: str) -> None:
diff --git a/mcp-server/polaris_mcp/tools/policy.py 
b/mcp-server/polaris_mcp/tools/policy.py
index 4029e5a..5463aa8 100644
--- a/mcp-server/polaris_mcp/tools/policy.py
+++ b/mcp-server/polaris_mcp/tools/policy.py
@@ -24,9 +24,6 @@ from __future__ import annotations
 import copy
 from typing import Any, Dict, Optional, Set
 
-import urllib3
-
-from polaris_mcp.authorization import AuthorizationProvider
 from polaris_mcp.base import (
     JSONDict,
     McpTool,
@@ -52,20 +49,8 @@ class PolarisPolicyTool(McpTool):
     DETACH_ALIASES: Set[str] = {"detach", "unmap", "unattach"}
     APPLICABLE_ALIASES: Set[str] = {"applicable", "applicable-policies"}
 
-    def __init__(
-        self,
-        base_url: str,
-        http: urllib3.PoolManager,
-        authorization_provider: AuthorizationProvider,
-    ) -> None:
-        self._delegate = PolarisRestTool(
-            name="polaris.policy.delegate",
-            description="Internal delegate for policy operations",
-            base_url=base_url,
-            default_path_prefix="api/catalog/polaris/v1/",
-            http=http,
-            authorization_provider=authorization_provider,
-        )
+    def __init__(self, rest_client: PolarisRestTool) -> None:
+        self._rest_client = rest_client
 
     @property
     def name(self) -> str:
@@ -184,7 +169,7 @@ class PolarisPolicyTool(McpTool):
         else:  # pragma: no cover
             raise ValueError(f"Unsupported operation: {operation}")
 
-        raw = self._delegate.call(delegate_args)
+        raw = self._rest_client.call(delegate_args)
         return self._maybe_augment_error(raw, normalized)
 
     def _handle_list(
diff --git a/mcp-server/polaris_mcp/tools/principal.py 
b/mcp-server/polaris_mcp/tools/principal.py
index 693990b..60470fb 100644
--- a/mcp-server/polaris_mcp/tools/principal.py
+++ b/mcp-server/polaris_mcp/tools/principal.py
@@ -24,9 +24,6 @@ from __future__ import annotations
 import copy
 from typing import Any, Dict, Optional, Set
 
-import urllib3
-
-from polaris_mcp.authorization import AuthorizationProvider
 from polaris_mcp.base import (
     JSONDict,
     McpTool,
@@ -57,20 +54,8 @@ class PolarisPrincipalTool(McpTool):
     ASSIGN_ROLE_ALIASES: Set[str] = {"assign-principal-role", "assign-role"}
     REVOKE_ROLE_ALIASES: Set[str] = {"revoke-principal-role", "revoke-role"}
 
-    def __init__(
-        self,
-        base_url: str,
-        http: urllib3.PoolManager,
-        authorization_provider: AuthorizationProvider,
-    ) -> None:
-        self._delegate = PolarisRestTool(
-            name="polaris.principal.delegate",
-            description="Internal delegate for principal operations",
-            base_url=base_url,
-            default_path_prefix="api/management/v1/",
-            http=http,
-            authorization_provider=authorization_provider,
-        )
+    def __init__(self, rest_client: PolarisRestTool) -> None:
+        self._rest_client = rest_client
 
     @property
     def name(self) -> str:
@@ -167,7 +152,7 @@ class PolarisPrincipalTool(McpTool):
         else:  # pragma: no cover
             raise ValueError(f"Unsupported operation: {operation}")
 
-        raw = self._delegate.call(delegate_args)
+        raw = self._rest_client.call(delegate_args)
         return self._maybe_augment_error(raw, normalized)
 
     def _handle_list(self, delegate_args: JSONDict) -> None:
diff --git a/mcp-server/polaris_mcp/tools/principal_role.py 
b/mcp-server/polaris_mcp/tools/principal_role.py
index 8fd84f7..2941769 100644
--- a/mcp-server/polaris_mcp/tools/principal_role.py
+++ b/mcp-server/polaris_mcp/tools/principal_role.py
@@ -23,9 +23,6 @@ from __future__ import annotations
 import copy
 from typing import Any, Dict, Optional, Set
 
-import urllib3
-
-from polaris_mcp.authorization import AuthorizationProvider
 from polaris_mcp.base import (
     JSONDict,
     McpTool,
@@ -61,20 +58,8 @@ class PolarisPrincipalRoleTool(McpTool):
         "remove-catalog-role",
     }
 
-    def __init__(
-        self,
-        base_url: str,
-        http: urllib3.PoolManager,
-        authorization_provider: AuthorizationProvider,
-    ) -> None:
-        self._delegate = PolarisRestTool(
-            name="polaris.principalrole.delegate",
-            description="Internal delegate for principal role operations",
-            base_url=base_url,
-            default_path_prefix="api/management/v1/",
-            http=http,
-            authorization_provider=authorization_provider,
-        )
+    def __init__(self, rest_client: PolarisRestTool) -> None:
+        self._rest_client = rest_client
 
     @property
     def name(self) -> str:
@@ -192,7 +177,7 @@ class PolarisPrincipalRoleTool(McpTool):
         else:  # pragma: no cover
             raise ValueError(f"Unsupported operation: {operation}")
 
-        raw = self._delegate.call(delegate_args)
+        raw = self._rest_client.call(delegate_args)
         return self._maybe_augment_error(raw, normalized)
 
     def _principal_role_path(self, arguments: Dict[str, Any]) -> str:
diff --git a/mcp-server/polaris_mcp/tools/table.py 
b/mcp-server/polaris_mcp/tools/table.py
index df02fb8..a92a40b 100644
--- a/mcp-server/polaris_mcp/tools/table.py
+++ b/mcp-server/polaris_mcp/tools/table.py
@@ -25,9 +25,6 @@ import copy
 import string
 from typing import Any, Dict, List, Set
 
-import urllib3
-
-from polaris_mcp.authorization import AuthorizationProvider
 from polaris_mcp.base import (
     JSONDict,
     McpTool,
@@ -50,20 +47,8 @@ class PolarisTableTool(McpTool):
     COMMIT_ALIASES: Set[str] = {"commit", "update"}
     DELETE_ALIASES: Set[str] = {"delete", "drop"}
 
-    def __init__(
-        self,
-        base_url: str,
-        http: urllib3.PoolManager,
-        authorization_provider: AuthorizationProvider,
-    ) -> None:
-        self._delegate = PolarisRestTool(
-            name="polaris.table.delegate",
-            description="Internal delegate for table operations",
-            base_url=base_url,
-            default_path_prefix="api/catalog/v1/",
-            http=http,
-            authorization_provider=authorization_provider,
-        )
+    def __init__(self, rest_client: PolarisRestTool) -> None:
+        self._rest_client = rest_client
 
     @property
     def name(self) -> str:
@@ -151,7 +136,7 @@ class PolarisTableTool(McpTool):
         else:  # pragma: no cover - defensive, normalize guarantees handled 
cases
             raise ValueError(f"Unsupported operation: {operation}")
 
-        return self._delegate.call(delegate_args)
+        return self._rest_client.call(delegate_args)
 
     def _handle_list(
         self, delegate_args: JSONDict, catalog: str, namespace: str
diff --git a/mcp-server/tests/test_authorization.py 
b/mcp-server/tests/test_authorization.py
new file mode 100644
index 0000000..4d57356
--- /dev/null
+++ b/mcp-server/tests/test_authorization.py
@@ -0,0 +1,140 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
+"""Unit tests for ``polaris_mcp.authorization``."""
+
+from __future__ import annotations
+
+import json
+import time
+from types import SimpleNamespace
+from unittest import mock
+
+import pytest
+
+from polaris_mcp.authorization import (
+    ClientCredentialsAuthorizationProvider,
+    StaticAuthorizationProvider,
+    none,
+)
+
+
+def test_static_authorization_provider_trims_and_formats() -> None:
+    provider = StaticAuthorizationProvider("  token123 ")
+    assert provider.authorization_header() == "Bearer token123"
+
+    empty = StaticAuthorizationProvider("   ")
+    assert empty.authorization_header() is None
+
+
+def test_none_authorization_provider_returns_none() -> None:
+    provider = none()
+    assert provider.authorization_header() is None
+
+
+def test_client_credentials_fetches_and_caches_tokens(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    http = mock.Mock()
+    now = time.time()
+    response = SimpleNamespace(
+        status=200,
+        data=json.dumps({"access_token": "abc", "expires_in": 
120}).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,
+    )
+
+    with mock.patch("time.time", return_value=now):
+        header1 = provider.authorization_header()
+        header2 = provider.authorization_header()
+
+    assert header1 == "Bearer abc"
+    assert header2 == "Bearer abc"
+
+    http.request.assert_called_once()
+    body = http.request.call_args.kwargs["body"]
+    assert "grant_type=client_credentials" in body
+    assert "client_id=client" in body
+    assert "client_secret=secret" in body
+
+    # Force expiry to trigger a refresh
+    http.request.reset_mock()
+    refreshed = SimpleNamespace(
+        status=200,
+        data=json.dumps({"access_token": "def", "expires_in": 
3600}).encode("utf-8"),
+    )
+    http.request.return_value = refreshed
+    with mock.patch("time.time", return_value=now + 4000):
+        header3 = provider.authorization_header()
+
+    assert header3 == "Bearer def"
+    http.request.assert_called_once()
+
+
[email protected](
+    "payload,expected_message",
+    [
+        ({"access_token": ""}, "missing access_token"),
+        ({"nope": "value"}, "missing access_token"),
+        ("not-json", "invalid JSON"),
+    ],
+)
+def test_client_credentials_rejects_invalid_responses(
+    payload: object, expected_message: str
+) -> None:
+    http = mock.Mock()
+    if isinstance(payload, str):
+        data = payload.encode("utf-8")
+    else:
+        data = json.dumps(payload).encode("utf-8")
+    http.request.return_value = SimpleNamespace(status=200, data=data)
+
+    provider = ClientCredentialsAuthorizationProvider(
+        token_endpoint="https://auth/token";,
+        client_id="client",
+        client_secret="secret",
+        scope=None,
+        http=http,
+    )
+
+    with pytest.raises(RuntimeError, match=expected_message):
+        provider.authorization_header()
+
+
+def test_client_credentials_errors_on_non_200_status() -> None:
+    http = mock.Mock()
+    http.request.return_value = SimpleNamespace(status=500, data=b"boom")
+
+    provider = ClientCredentialsAuthorizationProvider(
+        token_endpoint="https://auth/token";,
+        client_id="client",
+        client_secret="secret",
+        scope=None,
+        http=http,
+    )
+
+    with pytest.raises(RuntimeError, match="500"):
+        provider.authorization_header()
diff --git a/mcp-server/tests/test_catalog_role_tool.py 
b/mcp-server/tests/test_catalog_role_tool.py
new file mode 100644
index 0000000..ff90d2a
--- /dev/null
+++ b/mcp-server/tests/test_catalog_role_tool.py
@@ -0,0 +1,73 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
+"""Unit tests for ``polaris_mcp.tools.catalog_role``."""
+
+from __future__ import annotations
+
+import pytest
+from unittest import mock
+
+from polaris_mcp.base import ToolExecutionResult
+from polaris_mcp.tools.catalog_role import PolarisCatalogRoleTool
+
+
+def _build_tool() -> tuple[PolarisCatalogRoleTool, mock.Mock]:
+    rest_client = mock.Mock()
+    rest_client.call.return_value = ToolExecutionResult(text="ok", 
is_error=False)
+    tool = PolarisCatalogRoleTool(rest_client=rest_client)
+    return tool, rest_client
+
+
+def test_list_grants_builds_expected_path() -> None:
+    tool, rest_client = _build_tool()
+
+    tool.call({"operation": "list-grants", "catalog": "prod", "catalogRole": 
"analyst"})
+
+    payload = rest_client.call.call_args.args[0]
+    assert payload["method"] == "GET"
+    assert payload["path"] == "catalogs/prod/catalog-roles/analyst/grants"
+
+
+def test_add_grant_requires_body() -> None:
+    tool, rest_client = _build_tool()
+    body = {"principal": "alice", "permission": "READ"}
+
+    tool.call(
+        {
+            "operation": "grant",
+            "catalog": "prod",
+            "catalogRole": "analyst",
+            "body": body,
+        }
+    )
+
+    payload = rest_client.call.call_args.args[0]
+    assert payload["method"] == "PUT"
+    assert payload["path"] == "catalogs/prod/catalog-roles/analyst/grants"
+    assert payload["body"] == body
+    assert payload["body"] is not body  # it's a different object
+
+
+def test_add_grant_fails_without_body() -> None:
+    tool, _ = _build_tool()
+    with pytest.raises(ValueError, match="AddGrantRequest payload"):
+        tool.call(
+            {"operation": "add-grant", "catalog": "prod", "catalogRole": 
"analyst"}
+        )
diff --git a/mcp-server/tests/test_catalog_tool.py 
b/mcp-server/tests/test_catalog_tool.py
new file mode 100644
index 0000000..17d7197
--- /dev/null
+++ b/mcp-server/tests/test_catalog_tool.py
@@ -0,0 +1,76 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
+"""Unit tests for ``polaris_mcp.tools.catalog``."""
+
+from __future__ import annotations
+
+import pytest
+from unittest import mock
+
+from polaris_mcp.base import ToolExecutionResult
+from polaris_mcp.tools.catalog import PolarisCatalogTool
+
+
+def _build_tool() -> tuple[PolarisCatalogTool, mock.Mock]:
+    rest_client = mock.Mock()
+    rest_client.call.return_value = ToolExecutionResult(text="ok", 
is_error=False)
+    tool = PolarisCatalogTool(rest_client=rest_client)
+    return tool, rest_client
+
+
+def test_list_operation_uses_management_path() -> None:
+    tool, rest_client = _build_tool()
+
+    tool.call({"operation": "list"})
+
+    rest_client.call.assert_called_once()
+    payload = rest_client.call.call_args.args[0]
+    assert payload["method"] == "GET"
+    assert payload["path"] == "catalogs"
+
+
+def test_get_operation_requires_catalog_and_encodes() -> None:
+    tool, rest_client = _build_tool()
+
+    tool.call({"operation": "get", "catalog": "my catalog"})
+
+    payload = rest_client.call.call_args.args[0]
+    assert payload["method"] == "GET"
+    assert payload["path"] == "catalogs/my%20catalog"
+
+
+def test_create_operation_requires_body_and_copies_it() -> None:
+    tool, rest_client = _build_tool()
+    body = {"name": "c1", "properties": {"a": "b"}}
+
+    tool.call({"operation": "create", "body": body})
+
+    payload = rest_client.call.call_args.args[0]
+    assert payload["method"] == "POST"
+    assert payload["path"] == "catalogs"
+    assert payload["body"] == body
+    assert payload["body"] is not body
+    assert payload["body"]["properties"] is not body["properties"]
+
+
+def test_create_operation_requires_body_present() -> None:
+    tool, _ = _build_tool()
+    with pytest.raises(ValueError, match="Create operations require"):
+        tool.call({"operation": "create"})
diff --git a/mcp-server/tests/test_namespace_tool.py 
b/mcp-server/tests/test_namespace_tool.py
index 88af2b1..23f2527 100644
--- a/mcp-server/tests/test_namespace_tool.py
+++ b/mcp-server/tests/test_namespace_tool.py
@@ -1,3 +1,22 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
 """Unit tests for ``polaris_mcp.tools.namespace``."""
 
 from __future__ import annotations
@@ -8,21 +27,15 @@ from polaris_mcp.base import ToolExecutionResult
 from polaris_mcp.tools.namespace import PolarisNamespaceTool
 
 
-def _build_tool(mock_rest: mock.Mock) -> tuple[PolarisNamespaceTool, 
mock.Mock]:
-    delegate = mock.Mock()
-    delegate.call.return_value = ToolExecutionResult(text="done", 
is_error=False)
-    mock_rest.return_value = delegate
-    tool = PolarisNamespaceTool(
-        "https://polaris/";, mock.sentinel.http, mock.sentinel.auth
-    )
-    return tool, delegate
+def _build_tool() -> tuple[PolarisNamespaceTool, mock.Mock]:
+    rest_client = mock.Mock()
+    rest_client.call.return_value = ToolExecutionResult(text="done", 
is_error=False)
+    tool = PolarisNamespaceTool(rest_client=rest_client)
+    return tool, rest_client
 
 
[email protected]("polaris_mcp.tools.namespace.PolarisRestTool")
-def test_get_operation_encodes_namespace_with_unit_separator(
-    mock_rest: mock.Mock,
-) -> None:
-    tool, delegate = _build_tool(mock_rest)
+def test_get_operation_encodes_namespace_with_unit_separator() -> None:
+    tool, delegate = _build_tool()
 
     tool.call(
         {
@@ -38,11 +51,8 @@ def test_get_operation_encodes_namespace_with_unit_separator(
     assert payload["path"] == "prod/namespaces/analytics%1Fdaily"
 
 
[email protected]("polaris_mcp.tools.namespace.PolarisRestTool")
-def test_create_operation_infers_namespace_array_from_string(
-    mock_rest: mock.Mock,
-) -> None:
-    tool, delegate = _build_tool(mock_rest)
+def test_create_operation_infers_namespace_array_from_string() -> None:
+    tool, delegate = _build_tool()
     body = {"properties": {"owner": "analytics"}}
 
     tool.call(
diff --git a/mcp-server/tests/test_policy_tool.py 
b/mcp-server/tests/test_policy_tool.py
new file mode 100644
index 0000000..08e4aec
--- /dev/null
+++ b/mcp-server/tests/test_policy_tool.py
@@ -0,0 +1,59 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
+"""Unit tests for ``polaris_mcp.tools.policy``."""
+
+from __future__ import annotations
+
+import pytest
+from unittest import mock
+
+from polaris_mcp.base import ToolExecutionResult
+from polaris_mcp.tools.policy import PolarisPolicyTool
+
+
+def _build_tool() -> tuple[PolarisPolicyTool, mock.Mock]:
+    rest_client = mock.Mock()
+    rest_client.call.return_value = ToolExecutionResult(text="ok", 
is_error=False)
+    tool = PolarisPolicyTool(rest_client=rest_client)
+    return tool, rest_client
+
+
+def test_list_operation_requires_namespace_and_builds_path() -> None:
+    tool, rest_client = _build_tool()
+
+    tool.call({"operation": "list", "catalog": "prod", "namespace": 
"analytics.daily"})
+
+    payload = rest_client.call.call_args.args[0]
+    assert payload["method"] == "GET"
+    assert payload["path"] == "prod/namespaces/analytics.daily/policies"
+
+
+def test_create_operation_requires_body() -> None:
+    tool, _ = _build_tool()
+    with pytest.raises(ValueError, match="Create operations require"):
+        tool.call({"operation": "create", "catalog": "prod", "namespace": 
"ns"})
+
+
+def test_attach_operation_requires_policy() -> None:
+    tool, _ = _build_tool()
+    with pytest.raises(ValueError, match="Policy name is required"):
+        tool.call(
+            {"operation": "attach", "catalog": "prod", "namespace": "ns", 
"body": {}}
+        )
diff --git a/mcp-server/tests/test_principal_role_tool.py 
b/mcp-server/tests/test_principal_role_tool.py
new file mode 100644
index 0000000..b87bd9e
--- /dev/null
+++ b/mcp-server/tests/test_principal_role_tool.py
@@ -0,0 +1,62 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
+"""Unit tests for ``polaris_mcp.tools.principal_role``."""
+
+from __future__ import annotations
+
+import pytest
+from unittest import mock
+
+from polaris_mcp.base import ToolExecutionResult
+from polaris_mcp.tools.principal_role import PolarisPrincipalRoleTool
+
+
+def _build_tool() -> tuple[PolarisPrincipalRoleTool, mock.Mock]:
+    rest_client = mock.Mock()
+    rest_client.call.return_value = ToolExecutionResult(text="ok", 
is_error=False)
+    tool = PolarisPrincipalRoleTool(rest_client=rest_client)
+    return tool, rest_client
+
+
+def test_list_operation_sets_management_path() -> None:
+    tool, rest_client = _build_tool()
+
+    tool.call({"operation": "list"})
+
+    payload = rest_client.call.call_args.args[0]
+    assert payload["method"] == "GET"
+    assert payload["path"] == "principal-roles"
+
+
+def test_assign_catalog_role_requires_body() -> None:
+    tool, _ = _build_tool()
+
+    with pytest.raises(ValueError, match="Missing required field: catalog"):
+        tool.call({"operation": "assign-catalog-role", "principalRole": 
"analyst"})
+
+
+def test_get_operation_encodes_principal_role() -> None:
+    tool, rest_client = _build_tool()
+
+    tool.call({"operation": "get", "principalRole": "team role"})
+
+    payload = rest_client.call.call_args.args[0]
+    assert payload["method"] == "GET"
+    assert payload["path"] == "principal-roles/team%20role"
diff --git a/mcp-server/tests/test_principal_tool.py 
b/mcp-server/tests/test_principal_tool.py
new file mode 100644
index 0000000..cee6287
--- /dev/null
+++ b/mcp-server/tests/test_principal_tool.py
@@ -0,0 +1,64 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
+"""Unit tests for ``polaris_mcp.tools.principal``."""
+
+from __future__ import annotations
+
+import pytest
+from unittest import mock
+
+from polaris_mcp.base import ToolExecutionResult
+from polaris_mcp.tools.principal import PolarisPrincipalTool
+
+
+def _build_tool() -> tuple[PolarisPrincipalTool, mock.Mock]:
+    rest_client = mock.Mock()
+    rest_client.call.return_value = ToolExecutionResult(text="ok", 
is_error=False)
+    tool = PolarisPrincipalTool(rest_client=rest_client)
+    return tool, rest_client
+
+
+def test_list_operation_sets_management_path() -> None:
+    tool, rest_client = _build_tool()
+
+    tool.call({"operation": "list"})
+
+    payload = rest_client.call.call_args.args[0]
+    assert payload["method"] == "GET"
+    assert payload["path"] == "principals"
+
+
+def test_assign_role_requires_principal_and_body() -> None:
+    tool, _ = _build_tool()
+    with pytest.raises(ValueError, match="Missing required field: principal"):
+        tool.call({"operation": "assign-role"})
+
+    with pytest.raises(ValueError, match="GrantPrincipalRoleRequest"):
+        tool.call({"operation": "assign-role", "principal": "alice"})
+
+
+def test_get_operation_encodes_principal() -> None:
+    tool, rest_client = _build_tool()
+
+    tool.call({"operation": "get", "principal": "svc user"})
+
+    payload = rest_client.call.call_args.args[0]
+    assert payload["method"] == "GET"
+    assert payload["path"] == "principals/svc%20user"
diff --git a/mcp-server/tests/test_server.py b/mcp-server/tests/test_server.py
index 0322a56..9680cc8 100644
--- a/mcp-server/tests/test_server.py
+++ b/mcp-server/tests/test_server.py
@@ -1,3 +1,22 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
 """Unit tests for ``polaris_mcp.server`` helpers."""
 
 from __future__ import annotations
diff --git a/mcp-server/tests/test_table_tool.py 
b/mcp-server/tests/test_table_tool.py
index 88bd8ee..e3d5d9b 100644
--- a/mcp-server/tests/test_table_tool.py
+++ b/mcp-server/tests/test_table_tool.py
@@ -1,3 +1,22 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+
 """Unit tests for ``polaris_mcp.tools.table``."""
 
 from __future__ import annotations
@@ -9,21 +28,17 @@ from polaris_mcp.base import ToolExecutionResult
 from polaris_mcp.tools.table import PolarisTableTool
 
 
-def _build_tool(mock_rest: mock.Mock) -> tuple[PolarisTableTool, mock.Mock]:
-    delegate = mock.Mock()
-    delegate.call.return_value = ToolExecutionResult(
+def _build_tool() -> tuple[PolarisTableTool, mock.Mock]:
+    rest_client = mock.Mock()
+    rest_client.call.return_value = ToolExecutionResult(
         text="ok", is_error=False, metadata={"k": "v"}
     )
-    mock_rest.return_value = delegate
-    tool = PolarisTableTool("https://polaris/";, mock.sentinel.http, 
mock.sentinel.auth)
-    return tool, delegate
+    tool = PolarisTableTool(rest_client=rest_client)
+    return tool, rest_client
 
 
[email protected]("polaris_mcp.tools.table.PolarisRestTool")
-def test_list_operation_uses_get_and_copies_query_and_headers(
-    mock_rest: mock.Mock,
-) -> None:
-    tool, delegate = _build_tool(mock_rest)
+def test_list_operation_uses_get_and_copies_query_and_headers() -> None:
+    tool, delegate = _build_tool()
     arguments = {
         "operation": "LS",
         "catalog": "prod west",
@@ -45,9 +60,8 @@ def test_list_operation_uses_get_and_copies_query_and_headers(
     assert payload["headers"] is not arguments["headers"]
 
 
[email protected]("polaris_mcp.tools.table.PolarisRestTool")
-def test_get_operation_accepts_alias_and_encodes_table(mock_rest: mock.Mock) 
-> None:
-    tool, delegate = _build_tool(mock_rest)
+def test_get_operation_accepts_alias_and_encodes_table() -> None:
+    tool, delegate = _build_tool()
     arguments = {
         "operation": "fetch",
         "catalog": "prod",
@@ -64,17 +78,15 @@ def 
test_get_operation_accepts_alias_and_encodes_table(mock_rest: mock.Mock) ->
     assert "body" not in payload
 
 
[email protected]("polaris_mcp.tools.table.PolarisRestTool")
-def test_get_operation_requires_table_argument(mock_rest: mock.Mock) -> None:
-    tool, _ = _build_tool(mock_rest)
+def test_get_operation_requires_table_argument() -> None:
+    tool, _ = _build_tool()
 
     with pytest.raises(ValueError, match="Table name is required"):
         tool.call({"operation": "get", "catalog": "prod", "namespace": 
"analytics"})
 
 
[email protected]("polaris_mcp.tools.table.PolarisRestTool")
-def test_create_operation_deep_copies_request_body(mock_rest: mock.Mock) -> 
None:
-    tool, delegate = _build_tool(mock_rest)
+def test_create_operation_deep_copies_request_body() -> None:
+    tool, delegate = _build_tool()
     body = {"table": "t1", "properties": {"schema-id": 1}}
     tool.call(
         {
@@ -97,17 +109,15 @@ def 
test_create_operation_deep_copies_request_body(mock_rest: mock.Mock) -> None
     assert payload["body"]["properties"]["schema-id"] == 1
 
 
[email protected]("polaris_mcp.tools.table.PolarisRestTool")
-def test_create_operation_requires_body(mock_rest: mock.Mock) -> None:
-    tool, _ = _build_tool(mock_rest)
+def test_create_operation_requires_body() -> None:
+    tool, _ = _build_tool()
 
     with pytest.raises(ValueError, match="Create operations require"):
         tool.call({"operation": "create", "catalog": "prod", "namespace": 
"analytics"})
 
 
[email protected]("polaris_mcp.tools.table.PolarisRestTool")
-def test_commit_operation_requires_table_and_body(mock_rest: mock.Mock) -> 
None:
-    tool, _ = _build_tool(mock_rest)
+def test_commit_operation_requires_table_and_body() -> None:
+    tool, _ = _build_tool()
 
     with pytest.raises(ValueError, match="Table name is required"):
         tool.call(
@@ -130,9 +140,8 @@ def 
test_commit_operation_requires_table_and_body(mock_rest: mock.Mock) -> None:
         )
 
 
[email protected]("polaris_mcp.tools.table.PolarisRestTool")
-def test_commit_operation_post_request_with_body_copy(mock_rest: mock.Mock) -> 
None:
-    tool, delegate = _build_tool(mock_rest)
+def test_commit_operation_post_request_with_body_copy() -> None:
+    tool, delegate = _build_tool()
     body = {"changes": [{"type": "append", "snapshot-id": 5}]}
 
     tool.call(
@@ -157,9 +166,8 @@ def 
test_commit_operation_post_request_with_body_copy(mock_rest: mock.Mock) -> N
     assert payload["body"]["changes"][0]["snapshot-id"] == 5
 
 
[email protected]("polaris_mcp.tools.table.PolarisRestTool")
-def test_delete_operation_uses_alias_and_encodes_table(mock_rest: mock.Mock) 
-> None:
-    tool, delegate = _build_tool(mock_rest)
+def test_delete_operation_uses_alias_and_encodes_table() -> None:
+    tool, delegate = _build_tool()
 
     tool.call(
         {
@@ -176,9 +184,8 @@ def 
test_delete_operation_uses_alias_and_encodes_table(mock_rest: mock.Mock) ->
     assert payload["path"] == "prod/namespaces/analytics/tables/fact%20daily"
 
 
[email protected]("polaris_mcp.tools.table.PolarisRestTool")
-def test_namespace_validation_rejects_blank_values(mock_rest: mock.Mock) -> 
None:
-    tool, _ = _build_tool(mock_rest)
+def test_namespace_validation_rejects_blank_values() -> None:
+    tool, _ = _build_tool()
 
     with pytest.raises(ValueError, match="Namespace must be provided"):
         tool.call({"operation": "list", "catalog": "prod", "namespace": None})
diff --git a/mcp-server/uv.lock b/mcp-server/uv.lock
index cd9575c..8f0f62f 100644
--- a/mcp-server/uv.lock
+++ b/mcp-server/uv.lock
@@ -165,6 +165,15 @@ wheels = [
     { url = 
"https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl";,
 hash = 
"sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size 
= 184195, upload-time = "2025-09-08T23:23:43.004Z" },
 ]
 
+[[package]]
+name = "cfgv"
+version = "3.4.0"
+source = { registry = "https://pypi.org/simple"; }
+sdist = { url = 
"https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz";,
 hash = 
"sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size 
= 7114, upload-time = "2023-08-12T20:38:17.776Z" }
+wheels = [
+    { url = 
"https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl";,
 hash = 
"sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size 
= 7249, upload-time = "2023-08-12T20:38:16.269Z" },
+]
+
 [[package]]
 name = "charset-normalizer"
 version = "3.4.4"
@@ -366,6 +375,15 @@ wheels = [
     { url = 
"https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl";,
 hash = 
"sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size 
= 45550, upload-time = "2023-08-31T06:11:58.822Z" },
 ]
 
+[[package]]
+name = "distlib"
+version = "0.4.0"
+source = { registry = "https://pypi.org/simple"; }
+sdist = { url = 
"https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz";,
 hash = 
"sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size 
= 614605, upload-time = "2025-07-17T16:52:00.465Z" }
+wheels = [
+    { url = 
"https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl";,
 hash = 
"sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size 
= 469047, upload-time = "2025-07-17T16:51:58.613Z" },
+]
+
 [[package]]
 name = "dnspython"
 version = "2.8.0"
@@ -443,6 +461,15 @@ wheels = [
     { url = 
"https://files.pythonhosted.org/packages/bd/c6/95eacd687cfab64fec13bfb64e6c6e7da13d01ecd4cb7d7e991858a08119/fastmcp-2.13.0.2-py3-none-any.whl";,
 hash = 
"sha256:eb381eb073a101aabbc0ac44b05e23fef0cd1619344b7703115c825c8755fa1c", size 
= 367511, upload-time = "2025-10-28T13:56:18.83Z" },
 ]
 
+[[package]]
+name = "filelock"
+version = "3.20.0"
+source = { registry = "https://pypi.org/simple"; }
+sdist = { url = 
"https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz";,
 hash = 
"sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size 
= 18922, upload-time = "2025-10-08T18:03:50.056Z" }
+wheels = [
+    { url = 
"https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl";,
 hash = 
"sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size 
= 16054, upload-time = "2025-10-08T18:03:48.35Z" },
+]
+
 [[package]]
 name = "h11"
 version = "0.16.0"
@@ -489,6 +516,15 @@ wheels = [
     { url = 
"https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl";,
 hash = 
"sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size 
= 8960, upload-time = "2025-10-10T21:48:21.158Z" },
 ]
 
+[[package]]
+name = "identify"
+version = "2.6.15"
+source = { registry = "https://pypi.org/simple"; }
+sdist = { url = 
"https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz";,
 hash = 
"sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size 
= 99311, upload-time = "2025-10-02T17:43:40.631Z" }
+wheels = [
+    { url = 
"https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl";,
 hash = 
"sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size 
= 99183, upload-time = "2025-10-02T17:43:39.137Z" },
+]
+
 [[package]]
 name = "idna"
 version = "3.11"
@@ -677,6 +713,15 @@ wheels = [
     { url = 
"https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl";,
 hash = 
"sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size 
= 69667, upload-time = "2025-09-02T15:23:09.635Z" },
 ]
 
+[[package]]
+name = "nodeenv"
+version = "1.9.1"
+source = { registry = "https://pypi.org/simple"; }
+sdist = { url = 
"https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz";,
 hash = 
"sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size 
= 47437, upload-time = "2024-06-04T18:44:11.171Z" }
+wheels = [
+    { url = 
"https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl";,
 hash = 
"sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size 
= 22314, upload-time = "2024-06-04T18:44:08.352Z" },
+]
+
 [[package]]
 name = "openapi-pydantic"
 version = "0.5.1"
@@ -744,6 +789,9 @@ dependencies = [
 ]
 
 [package.optional-dependencies]
+dev = [
+    { name = "pre-commit" },
+]
 test = [
     { name = "pytest" },
 ]
@@ -751,10 +799,27 @@ test = [
 [package.metadata]
 requires-dist = [
     { name = "fastmcp", specifier = ">=2.13.0.2" },
+    { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.7" },
     { name = "pytest", marker = "extra == 'test'", specifier = ">=8.2" },
     { name = "urllib3", specifier = ">=1.25.3,<3.0.0" },
 ]
-provides-extras = ["test"]
+provides-extras = ["test", "dev"]
+
+[[package]]
+name = "pre-commit"
+version = "4.4.0"
+source = { registry = "https://pypi.org/simple"; }
+dependencies = [
+    { name = "cfgv" },
+    { name = "identify" },
+    { name = "nodeenv" },
+    { name = "pyyaml" },
+    { name = "virtualenv" },
+]
+sdist = { url = 
"https://files.pythonhosted.org/packages/a6/49/7845c2d7bf6474efd8e27905b51b11e6ce411708c91e829b93f324de9929/pre_commit-4.4.0.tar.gz";,
 hash = 
"sha256:f0233ebab440e9f17cabbb558706eb173d19ace965c68cdce2c081042b4fab15", size 
= 197501, upload-time = "2025-11-08T21:12:11.607Z" }
+wheels = [
+    { url = 
"https://files.pythonhosted.org/packages/27/11/574fe7d13acf30bfd0a8dd7fa1647040f2b8064f13f43e8c963b1e65093b/pre_commit-4.4.0-py2.py3-none-any.whl";,
 hash = 
"sha256:b35ea52957cbf83dcc5d8ee636cbead8624e3a15fbfa61a370e42158ac8a5813", size 
= 226049, upload-time = "2025-11-08T21:12:10.228Z" },
+]
 
 [[package]]
 name = "py-key-value-aio"
@@ -1435,6 +1500,21 @@ wheels = [
     { url = 
"https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl";,
 hash = 
"sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size 
= 68109, upload-time = "2025-10-18T13:46:42.958Z" },
 ]
 
+[[package]]
+name = "virtualenv"
+version = "20.35.4"
+source = { registry = "https://pypi.org/simple"; }
+dependencies = [
+    { name = "distlib" },
+    { name = "filelock" },
+    { name = "platformdirs" },
+    { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = 
"https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz";,
 hash = 
"sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size 
= 6028799, upload-time = "2025-10-29T06:57:40.511Z" }
+wheels = [
+    { url = 
"https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl";,
 hash = 
"sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size 
= 6005095, upload-time = "2025-10-29T06:57:37.598Z" },
+]
+
 [[package]]
 name = "websockets"
 version = "15.0.1"

Reply via email to