diff --git a/.gitignore b/.gitignore index acc8f26d31..5e92f80e1d 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,9 @@ client/python/polaris/ client/python/test/ !client/python/test/test_cli_parsing.py +# Python packaging metadata +*.egg-info/ + # Polaris CLI profile .polaris.json diff --git a/client/python-mcp/README.md b/client/python-mcp/README.md new file mode 100644 index 0000000000..11cb7dc526 --- /dev/null +++ b/client/python-mcp/README.md @@ -0,0 +1,103 @@ + + +# Apache Polaris MCP Server (Python) + +This package provides a Python implementation of the [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server for Apache Polaris. It wraps the Polaris REST APIs so MCP-compatible clients (IDEs, agents, chat applications) can issue structured requests via JSON-RPC on stdin/stdout. + +The implementation is built on top of [FastMCP](https://gofastmcp.com) for streamlined server registration and transport handling. + +## Installation + +From the repository root: + +```bash +cd client/python-mcp +uv sync +``` + +## Running + +Launch the MCP server (which reads from stdin and writes to stdout): + +```bash +uv run polaris-mcp +``` + +Example interaction: + +```json +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"clientInfo":{"name":"manual","version":"0"}}} +{"jsonrpc":"2.0","id":2,"method":"tools/list"} +``` + +For a `tools/call` invocation you will typically set environment variables such as `POLARIS_BASE_URL` and authentication settings before launching the server. + +### Claude Desktop configuration + +```json +{ + "mcpServers": { + "polaris": { + "command": "uv", + "args": [ + "--directory", + "/path/to/polaris/client/python-mcp", + "run", + "polaris-mcp" + ], + "env": { + "POLARIS_BASE_URL": "http://localhost:8181/", + "POLARIS_CLIENT_ID": "root", + "POLARIS_CLIENT_SECRET": "s3cr3t", + "POLARIS_TOKEN_SCOPE": "PRINCIPAL_ROLE:ALL" + } + } + } +} +``` + +Please note: `--directory` specifies a local directory. It is not needed when we pull `polaris-mcp` from PyPI package. + +## 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` | + +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. + +## Tools + +The server exposes the following MCP tools: + +* `polaris-iceberg-table` — Table operations (`list`, `get`, `create`, `update`, `delete`). +* `polaris-namespace-request` — Namespace lifecycle management. +* `polaris-policy` — Policy lifecycle management and mappings. +* `polaris-catalog-request` — Catalog lifecycle management. +* `polaris-principal-request` — Principal lifecycle helpers. +* `polaris-principal-role-request` — Principal role lifecycle and catalog-role assignments. +* `polaris-catalog-role-request` — Catalog role and grant management. + +Each tool returns both a human-readable transcript of the HTTP exchange and structured metadata under `result.meta`. diff --git a/client/python-mcp/polaris_mcp/__init__.py b/client/python-mcp/polaris_mcp/__init__.py new file mode 100644 index 0000000000..db666ab2ad --- /dev/null +++ b/client/python-mcp/polaris_mcp/__init__.py @@ -0,0 +1,24 @@ +# +# 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. +# + +"""Polaris Model Context Protocol server implementation.""" + +from .server import create_server, main + +__all__ = ["create_server", "main"] diff --git a/client/python-mcp/polaris_mcp/authorization.py b/client/python-mcp/polaris_mcp/authorization.py new file mode 100644 index 0000000000..2955d3325f --- /dev/null +++ b/client/python-mcp/polaris_mcp/authorization.py @@ -0,0 +1,136 @@ +# +# 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. +# + +"""Authorization helpers for the Polaris MCP server.""" + +from __future__ import annotations + +import json +import threading +import time +from abc import ABC, abstractmethod +from typing import Optional +from urllib.parse import urlencode + +import urllib3 + + +class AuthorizationProvider(ABC): + """Return Authorization header values for outgoing requests.""" + + @abstractmethod + def authorization_header(self) -> Optional[str]: + ... + + +class StaticAuthorizationProvider(AuthorizationProvider): + """Wrap a static bearer token.""" + + def __init__(self, token: Optional[str]) -> None: + value = (token or "").strip() + self._header = f"Bearer {value}" if value else None + + def authorization_header(self) -> Optional[str]: + return self._header + + +class ClientCredentialsAuthorizationProvider(AuthorizationProvider): + """Implements the OAuth client-credentials flow with caching.""" + + def __init__( + self, + token_endpoint: str, + client_id: str, + client_secret: str, + scope: Optional[str], + http: urllib3.PoolManager, + ) -> None: + self._token_endpoint = token_endpoint + self._client_id = client_id + self._client_secret = client_secret + self._scope = scope + self._http = http + self._lock = threading.Lock() + self._cached: Optional[tuple[str, float]] = None # (token, expires_at_epoch) + + def authorization_header(self) -> Optional[str]: + token = self._current_token() + return f"Bearer {token}" if token else None + + def _current_token(self) -> Optional[str]: + now = time.time() + cached = self._cached + if not cached or cached[1] - 60 <= now: + with self._lock: + cached = self._cached + if not cached or cached[1] - 60 <= time.time(): + self._cached = cached = self._fetch_token() + return cached[0] if cached else None + + def _fetch_token(self) -> tuple[str, float]: + payload = { + "grant_type": "client_credentials", + "client_id": self._client_id, + "client_secret": self._client_secret, + } + if self._scope: + payload["scope"] = self._scope + + encoded = urlencode(payload) + response = self._http.request( + "POST", + self._token_endpoint, + body=encoded, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=urllib3.Timeout(connect=20.0, read=20.0), + ) + + if response.status != 200: + raise RuntimeError( + f"OAuth token endpoint returned {response.status}: {response.data.decode('utf-8', errors='ignore')}" + ) + + try: + document = json.loads(response.data.decode("utf-8")) + except json.JSONDecodeError as error: + raise RuntimeError("OAuth token endpoint returned invalid JSON") from error + + token = document.get("access_token") + if not isinstance(token, str) or not token: + raise RuntimeError("OAuth token response missing access_token") + + expires_in = document.get("expires_in", 3600) + try: + ttl = float(expires_in) + except (TypeError, ValueError): + ttl = 3600.0 + ttl = max(ttl, 60.0) + expires_at = time.time() + ttl + return token, expires_at + + +class _NoneAuthorizationProvider(AuthorizationProvider): + def authorization_header(self) -> Optional[str]: + return None + + +def none() -> AuthorizationProvider: + """Return an AuthorizationProvider that never supplies a header.""" + + return _NoneAuthorizationProvider() diff --git a/client/python-mcp/polaris_mcp/base.py b/client/python-mcp/polaris_mcp/base.py new file mode 100644 index 0000000000..4072e155d3 --- /dev/null +++ b/client/python-mcp/polaris_mcp/base.py @@ -0,0 +1,55 @@ +# +# 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. +# + +"""Shared protocol definitions for the Polaris MCP server.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, Optional, Protocol + + +JSONDict = Dict[str, Any] + + +@dataclass(frozen=True) +class ToolExecutionResult: + """Structured result returned from executing an MCP tool.""" + + text: str + is_error: bool + metadata: Optional[JSONDict] = None + + +class McpTool(Protocol): + """Protocol describing the minimal surface for MCP tools.""" + + @property + def name(self) -> str: # pragma: no cover - simple accessor + ... + + @property + def description(self) -> str: # pragma: no cover - simple accessor + ... + + def input_schema(self) -> JSONDict: + """Return a JSON schema describing the tool parameters.""" + + def call(self, arguments: Any) -> ToolExecutionResult: + """Execute the tool with the provided JSON arguments.""" diff --git a/client/python-mcp/polaris_mcp/rest.py b/client/python-mcp/polaris_mcp/rest.py new file mode 100644 index 0000000000..6db6ee2f7b --- /dev/null +++ b/client/python-mcp/polaris_mcp/rest.py @@ -0,0 +1,303 @@ +# +# 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. +# + +"""HTTP helper used by the Polaris MCP tools.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import urlencode, urljoin, urlsplit, urlunsplit + +import urllib3 + +from .authorization import AuthorizationProvider, none +from .base import JSONDict, ToolExecutionResult + + +DEFAULT_TIMEOUT = urllib3.Timeout(connect=30.0, read=30.0) + + +def _ensure_trailing_slash(url: str) -> str: + return url if url.endswith("/") else f"{url}/" + + +def _normalize_prefix(prefix: Optional[str]) -> str: + if not prefix: + return "" + trimmed = prefix.strip() + if trimmed.startswith("/"): + trimmed = trimmed[1:] + if trimmed and not trimmed.endswith("/"): + trimmed = f"{trimmed}/" + return trimmed + + +def _merge_headers(values: Optional[Dict[str, Any]]) -> Dict[str, str]: + headers: Dict[str, str] = {"Accept": "application/json"} + if not values: + return headers + + for name, raw_value in values.items(): + if not name or raw_value is None: + continue + if isinstance(raw_value, list): + flattened = [str(item) for item in raw_value if item is not None] + if not flattened: + continue + headers[name] = ", ".join(flattened) + else: + headers[name] = str(raw_value) + return headers + + +def _serialize_body(node: Any) -> Optional[str]: + if node is None: + return None + if isinstance(node, (str, bytes)): + return node.decode("utf-8") if isinstance(node, bytes) else node + return json.dumps(node) + + +def _pretty_body(raw: str) -> str: + if not raw.strip(): + return "" + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + return raw + return json.dumps(parsed, indent=2) + + +def _headers_to_dict(headers: urllib3.response.HTTPHeaderDict) -> Dict[str, str]: + flattened: Dict[str, str] = {} + for key in headers: + values = headers.getlist(key) + flattened[key] = ", ".join(values) + return flattened + + +def _append_query(url: str, params: List[Tuple[str, str]]) -> str: + if not params: + return url + parsed = urlsplit(url) + extra_parts = [urlencode({k: v}) for k, v in params if v is not None] + existing = parsed.query + if existing: + query = "&".join([existing] + extra_parts) if extra_parts else existing + else: + query = "&".join(extra_parts) + return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, query, parsed.fragment)) + + +def _build_query(parameters: Optional[Dict[str, Any]]) -> List[Tuple[str, str]]: + if not parameters: + return [] + entries: List[Tuple[str, str]] = [] + for key, value in parameters.items(): + if not key or value is None: + continue + if isinstance(value, list): + for item in value: + if item is not None: + entries.append((key, str(item))) + else: + entries.append((key, str(value))) + return entries + + +def _maybe_parse_json(text: Optional[str]) -> Tuple[Optional[Any], Optional[str]]: + if text is None: + return None, None + try: + return json.loads(text), None + except json.JSONDecodeError: + return None, text + + +class PolarisRestTool: + """Issues HTTP requests against the Polaris REST API and packages the response.""" + + def __init__( + self, + name: str, + description: str, + base_url: str, + default_path_prefix: str, + http: urllib3.PoolManager, + authorization_provider: Optional[AuthorizationProvider] = None, + ) -> None: + self._name = name + self._description = description + self._base_url = _ensure_trailing_slash(base_url) + self._path_prefix = _normalize_prefix(default_path_prefix) + self._http = http + self._authorization = authorization_provider or none() + + @property + def name(self) -> str: + return self._name + + @property + def description(self) -> str: + return self._description + + def input_schema(self) -> JSONDict: + """Return the generic JSON schema shared by delegated tools.""" + return { + "type": "object", + "properties": { + "method": { + "type": "string", + "description": ( + "HTTP method, e.g. GET, POST, PUT, DELETE, PATCH, HEAD or OPTIONS. " + "Defaults to GET." + ), + }, + "path": { + "type": "string", + "description": ( + "Relative path under the Polaris base URL, such as " + "/api/management/v1/catalogs. Absolute URLs are also accepted." + ), + }, + "query": { + "type": "object", + "description": ( + "Optional query string parameters. Values can be strings or arrays of strings." + ), + "additionalProperties": { + "anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}}] + }, + }, + "headers": { + "type": "object", + "description": ( + "Optional request headers. Accept and Authorization headers are supplied " + "automatically when omitted." + ), + "additionalProperties": { + "anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}}] + }, + }, + "body": { + "type": ["object", "array", "string", "number", "boolean", "null"], + "description": ( + "Optional request body. Objects and arrays are serialized as JSON, strings " + "are sent as-is." + ), + }, + }, + "required": ["path"], + } + + def call(self, arguments: Any) -> ToolExecutionResult: + if not isinstance(arguments, dict): + raise ValueError("Tool arguments must be a JSON object.") + + method = str(arguments.get("method", "GET") or "GET").strip().upper() or "GET" + path = self._require_path(arguments) + query_params = arguments.get("query") + headers_param = arguments.get("headers") + body_node = arguments.get("body") + + query = query_params if isinstance(query_params, dict) else None + headers = headers_param if isinstance(headers_param, dict) else None + + target_uri = self._resolve_target_uri(path, query) + + header_values = _merge_headers(headers) + if not any(name.lower() == "authorization" for name in header_values): + token = self._authorization.authorization_header() + if token: + header_values["Authorization"] = token + + body_text = _serialize_body(body_node) + if body_text is not None and not any(name.lower() == "content-type" for name in header_values): + header_values["Content-Type"] = "application/json" + + response = self._http.request( + method, + target_uri, + body=body_text.encode("utf-8") if body_text is not None else None, + headers=header_values, + timeout=DEFAULT_TIMEOUT, + ) + + response_body = response.data.decode("utf-8") if response.data else "" + rendered_body = _pretty_body(response_body) + + lines = [f"{method} {target_uri}", f"Status: {response.status}"] + for key, value in _headers_to_dict(response.headers).items(): + lines.append(f"{key}: {value}") + if rendered_body: + lines.append("") + lines.append(rendered_body) + message = "\n".join(lines) + + metadata: JSONDict = { + "method": method, + "url": target_uri, + "status": response.status, + "request": { + "method": method, + "url": target_uri, + "headers": dict(header_values), + }, + "response": { + "status": response.status, + "headers": _headers_to_dict(response.headers), + }, + } + + if body_text is not None: + parsed, fallback = _maybe_parse_json(body_text) + if parsed is not None: + metadata["request"]["body"] = parsed + elif fallback is not None: + metadata["request"]["bodyText"] = fallback + + if response_body.strip(): + parsed, fallback = _maybe_parse_json(response_body) + if parsed is not None: + metadata["response"]["body"] = parsed + elif fallback is not None: + metadata["response"]["bodyText"] = fallback + + is_error = response.status >= 400 + return ToolExecutionResult(message, is_error, metadata) + + def _require_path(self, args: Dict[str, Any]) -> str: + path = args.get("path") + if not isinstance(path, str) or not path.strip(): + raise ValueError("The 'path' argument must be provided and must not be empty.") + return path.strip() + + def _resolve_target_uri(self, path: str, query: Optional[Dict[str, Any]]) -> str: + if path.startswith(("http://", "https://")): + target = path + else: + relative = path[1:] if path.startswith("/") else path + if self._path_prefix: + relative = f"{self._path_prefix}{relative}" + target = urljoin(self._base_url, relative) + + params = _build_query(query) + return _append_query(target, params) diff --git a/client/python-mcp/polaris_mcp/server.py b/client/python-mcp/polaris_mcp/server.py new file mode 100644 index 0000000000..0a8b2ff1c6 --- /dev/null +++ b/client/python-mcp/polaris_mcp/server.py @@ -0,0 +1,438 @@ +# +# 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. +# + +"""Entry point for the Polaris Model Context Protocol server.""" + +from __future__ import annotations + +import os +from typing import Any, Mapping, MutableMapping, Sequence +from urllib.parse import urljoin + +import urllib3 +from fastmcp import FastMCP +from fastmcp.tools.tool import ToolResult as FastMcpToolResult +from importlib import metadata +from mcp.types import TextContent + +from .authorization import ( + AuthorizationProvider, + ClientCredentialsAuthorizationProvider, + StaticAuthorizationProvider, + none, +) +from .base import ToolExecutionResult +from .tools import ( + PolarisCatalogRoleTool, + PolarisCatalogTool, + PolarisNamespaceTool, + PolarisPolicyTool, + PolarisPrincipalRoleTool, + PolarisPrincipalTool, + PolarisTableTool, +) + +DEFAULT_BASE_URL = "http://localhost:8181/" +OUTPUT_SCHEMA = { + "type": "object", + "properties": { + "isError": {"type": "boolean"}, + "meta": {"type": "object"}, + }, + "required": ["isError"], + "additionalProperties": True, +} + + +def create_server() -> FastMCP: + """Construct a FastMCP server with Polaris tools.""" + + 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_role_tool = PolarisCatalogRoleTool(base_url, http, authorization_provider) + policy_tool = PolarisPolicyTool(base_url, http, authorization_provider) + catalog_tool = PolarisCatalogTool(base_url, http, authorization_provider) + + server_version = _resolve_package_version() + mcp = FastMCP( + name="polaris-mcp", + version=server_version, + ) + + @mcp.tool( + name=table_tool.name, + description=table_tool.description, + output_schema=OUTPUT_SCHEMA, + ) + def polaris_iceberg_table( + operation: str, + catalog: str, + namespace: str | Sequence[str], + table: str | None = None, + query: Mapping[str, str | Sequence[str]] | None = None, + headers: Mapping[str, str | Sequence[str]] | None = None, + body: Any | None = None, + ) -> FastMcpToolResult: + return _call_tool( + table_tool, + required={ + "operation": operation, + "catalog": catalog, + "namespace": namespace, + }, + optional={ + "table": table, + "query": query, + "headers": headers, + "body": body, + }, + transforms={ + "namespace": _normalize_namespace, + "query": _copy_mapping, + "headers": _copy_mapping, + "body": _coerce_body, + }, + ) + + @mcp.tool( + name=namespace_tool.name, + description=namespace_tool.description, + output_schema=OUTPUT_SCHEMA, + ) + def polaris_namespace_request( + operation: str, + catalog: str, + namespace: str | Sequence[str] | None = None, + query: Mapping[str, str | Sequence[str]] | None = None, + headers: Mapping[str, str | Sequence[str]] | None = None, + body: Any | None = None, + ) -> FastMcpToolResult: + return _call_tool( + namespace_tool, + required={ + "operation": operation, + "catalog": catalog, + }, + optional={ + "namespace": namespace, + "query": query, + "headers": headers, + "body": body, + }, + transforms={ + "namespace": _normalize_namespace, + "query": _copy_mapping, + "headers": _copy_mapping, + "body": _coerce_body, + }, + ) + + @mcp.tool( + name=principal_tool.name, + description=principal_tool.description, + output_schema=OUTPUT_SCHEMA, + ) + def polaris_principal_request( + operation: str, + principal: str | None = None, + principalRole: str | None = None, + query: Mapping[str, str | Sequence[str]] | None = None, + headers: Mapping[str, str | Sequence[str]] | None = None, + body: Any | None = None, + ) -> FastMcpToolResult: + return _call_tool( + principal_tool, + required={"operation": operation}, + optional={ + "principal": principal, + "principalRole": principalRole, + "query": query, + "headers": headers, + "body": body, + }, + transforms={ + "query": _copy_mapping, + "headers": _copy_mapping, + "body": _coerce_body, + }, + ) + + @mcp.tool( + name=principal_role_tool.name, + description=principal_role_tool.description, + output_schema=OUTPUT_SCHEMA, + ) + def polaris_principal_role_request( + operation: str, + principalRole: str | None = None, + catalog: str | None = None, + catalogRole: str | None = None, + query: Mapping[str, str | Sequence[str]] | None = None, + headers: Mapping[str, str | Sequence[str]] | None = None, + body: Any | None = None, + ) -> FastMcpToolResult: + return _call_tool( + principal_role_tool, + required={"operation": operation}, + optional={ + "principalRole": principalRole, + "catalog": catalog, + "catalogRole": catalogRole, + "query": query, + "headers": headers, + "body": body, + }, + transforms={ + "query": _copy_mapping, + "headers": _copy_mapping, + "body": _coerce_body, + }, + ) + + @mcp.tool( + name=catalog_role_tool.name, + description=catalog_role_tool.description, + output_schema=OUTPUT_SCHEMA, + ) + def polaris_catalog_role_request( + operation: str, + catalog: str, + catalogRole: str | None = None, + query: Mapping[str, str | Sequence[str]] | None = None, + headers: Mapping[str, str | Sequence[str]] | None = None, + body: Any | None = None, + ) -> FastMcpToolResult: + return _call_tool( + catalog_role_tool, + required={ + "operation": operation, + "catalog": catalog, + }, + optional={ + "catalogRole": catalogRole, + "query": query, + "headers": headers, + "body": body, + }, + transforms={ + "query": _copy_mapping, + "headers": _copy_mapping, + "body": _coerce_body, + }, + ) + + @mcp.tool( + name=policy_tool.name, + description=policy_tool.description, + output_schema=OUTPUT_SCHEMA, + ) + def polaris_policy_request( + operation: str, + catalog: str, + namespace: str | Sequence[str] | None = None, + policy: str | None = None, + query: Mapping[str, str | Sequence[str]] | None = None, + headers: Mapping[str, str | Sequence[str]] | None = None, + body: Any | None = None, + ) -> FastMcpToolResult: + return _call_tool( + policy_tool, + required={ + "operation": operation, + "catalog": catalog, + }, + optional={ + "namespace": namespace, + "policy": policy, + "query": query, + "headers": headers, + "body": body, + }, + transforms={ + "namespace": _normalize_namespace, + "query": _copy_mapping, + "headers": _copy_mapping, + "body": _coerce_body, + }, + ) + + @mcp.tool( + name=catalog_tool.name, + description=catalog_tool.description, + output_schema=OUTPUT_SCHEMA, + ) + def polaris_catalog_request( + operation: str, + catalog: str | None = None, + query: Mapping[str, str | Sequence[str]] | None = None, + headers: Mapping[str, str | Sequence[str]] | None = None, + body: Any | None = None, + ) -> FastMcpToolResult: + return _call_tool( + catalog_tool, + required={"operation": operation}, + optional={ + "catalog": catalog, + "query": query, + "headers": headers, + "body": body, + }, + transforms={ + "query": _copy_mapping, + "headers": _copy_mapping, + "body": _coerce_body, + }, + ) + + return mcp + + +def _call_tool( + tool: Any, + *, + required: Mapping[str, Any], + optional: Mapping[str, Any | None] | None = None, + transforms: Mapping[str, Any] | None = None, +) -> FastMcpToolResult: + arguments: MutableMapping[str, Any] = dict(required) + if optional: + for key, value in optional.items(): + if value is not None: + arguments[key] = value + if transforms: + for key, transform in transforms.items(): + if key in arguments and arguments[key] is not None: + arguments[key] = transform(arguments[key]) + return _to_tool_result(tool.call(arguments)) + + +def _to_tool_result(result: ToolExecutionResult) -> FastMcpToolResult: + structured = {"isError": result.is_error} + if result.metadata is not None: + structured["meta"] = result.metadata + return FastMcpToolResult( + content=[TextContent(type="text", text=result.text)], + structured_content=structured, + ) + + +def _copy_mapping( + mapping: Mapping[str, Any] | None, +) -> MutableMapping[str, Any] | None: + if mapping is None: + return None + copied: MutableMapping[str, Any] = {} + for key, value in mapping.items(): + if value is None: + continue + if isinstance(value, (list, tuple)): + copied[key] = [str(item) for item in value] + else: + copied[key] = value + return copied + + +def _coerce_body(body: Any) -> Any: + if isinstance(body, Mapping): + return dict(body) + return body + + +def _normalize_namespace(namespace: str | Sequence[str]) -> str | list[str]: + if isinstance(namespace, str): + return namespace + return [str(part) for part in namespace] + + +def _resolve_base_url() -> str: + for candidate in ( + os.getenv("POLARIS_BASE_URL"), + os.getenv("POLARIS_REST_BASE_URL"), + ): + if candidate and candidate.strip(): + return candidate.strip() + return DEFAULT_BASE_URL + + +def _resolve_authorization_provider( + base_url: str, http: urllib3.PoolManager +) -> AuthorizationProvider: + token = _resolve_token() + if token: + return StaticAuthorizationProvider(token) + + client_id = _first_non_blank( + os.getenv("POLARIS_CLIENT_ID"), + ) + client_secret = _first_non_blank( + os.getenv("POLARIS_CLIENT_SECRET"), + ) + + if client_id and client_secret: + 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") + return ClientCredentialsAuthorizationProvider( + token_endpoint=endpoint, + client_id=client_id, + client_secret=client_secret, + scope=scope, + http=http, + ) + + return none() + + +def _resolve_token() -> str | None: + return _first_non_blank( + os.getenv("POLARIS_API_TOKEN"), + os.getenv("POLARIS_BEARER_TOKEN"), + os.getenv("POLARIS_TOKEN"), + ) + + +def _first_non_blank(*candidates: str | None) -> str | None: + for candidate in candidates: + if candidate and candidate.strip(): + return candidate.strip() + return None + + +def _resolve_package_version() -> str: + try: + return metadata.version("polaris-mcp") + except metadata.PackageNotFoundError: + return "dev" + + +def main() -> None: + """Script entry point.""" + + server = create_server() + server.run() + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/client/python-mcp/polaris_mcp/tools/__init__.py b/client/python-mcp/polaris_mcp/tools/__init__.py new file mode 100644 index 0000000000..dd46d2154f --- /dev/null +++ b/client/python-mcp/polaris_mcp/tools/__init__.py @@ -0,0 +1,38 @@ +# +# 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. +# + +"""Tool definitions exposed by the Polaris MCP server.""" + +from .catalog import PolarisCatalogTool +from .catalog_role import PolarisCatalogRoleTool +from .namespace import PolarisNamespaceTool +from .policy import PolarisPolicyTool +from .principal import PolarisPrincipalTool +from .principal_role import PolarisPrincipalRoleTool +from .table import PolarisTableTool + +__all__ = [ + "PolarisCatalogRoleTool", + "PolarisCatalogTool", + "PolarisNamespaceTool", + "PolarisPolicyTool", + "PolarisPrincipalRoleTool", + "PolarisPrincipalTool", + "PolarisTableTool", +] diff --git a/client/python-mcp/polaris_mcp/tools/catalog.py b/client/python-mcp/polaris_mcp/tools/catalog.py new file mode 100644 index 0000000000..72fca9e7f5 --- /dev/null +++ b/client/python-mcp/polaris_mcp/tools/catalog.py @@ -0,0 +1,204 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# 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. +# + +"""Catalog MCP tool.""" + +from __future__ import annotations + +import copy +from typing import Any, Dict, Optional, Set + +import urllib3 + +from ..authorization import AuthorizationProvider +from ..base import JSONDict, McpTool, ToolExecutionResult +from ..rest import PolarisRestTool + + +class PolarisCatalogTool(McpTool): + """Interact with the Polaris management API for catalog lifecycle operations.""" + + TOOL_NAME = "polaris-catalog-request" + TOOL_DESCRIPTION = ( + "Interact with the Polaris management API for catalog lifecycle operations." + ) + + LIST_ALIASES: Set[str] = {"list"} + GET_ALIASES: Set[str] = {"get"} + CREATE_ALIASES: Set[str] = {"create"} + 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, + ) + + @property + def name(self) -> str: + return self.TOOL_NAME + + @property + def description(self) -> str: + return self.TOOL_DESCRIPTION + + def input_schema(self) -> JSONDict: + return { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["list", "get", "create", "update", "delete"], + "description": ( + "Catalog operation to execute. Supported values: list, get, create, update, delete." + ), + }, + "catalog": { + "type": "string", + "description": ( + "Catalog name (required for get, update, delete). Automatically appended to the path." + ), + }, + "query": { + "type": "object", + "description": "Optional query parameters.", + "additionalProperties": {"type": "string"}, + }, + "headers": { + "type": "object", + "description": "Optional request headers.", + "additionalProperties": {"type": "string"}, + }, + "body": { + "type": "object", + "description": ( + "Optional request body payload for create/update. See polaris-management-service.yml." + ), + }, + }, + "required": ["operation"], + } + + def call(self, arguments: Any) -> ToolExecutionResult: + if not isinstance(arguments, dict): + raise ValueError("Tool arguments must be a JSON object.") + + operation = self._require_text(arguments, "operation").lower().strip() + normalized = self._normalize_operation(operation) + + delegate_args: JSONDict = {} + self._copy_if_object(arguments.get("query"), delegate_args, "query") + self._copy_if_object(arguments.get("headers"), delegate_args, "headers") + + if normalized == "list": + delegate_args["method"] = "GET" + delegate_args["path"] = "catalogs" + elif normalized == "get": + catalog_name = self._require_text(arguments, "catalog") + delegate_args["method"] = "GET" + delegate_args["path"] = f"catalogs/{catalog_name}" + elif normalized == "create": + body = arguments.get("body") + if not isinstance(body, dict): + raise ValueError( + "Create operations require a body matching CreateCatalogRequest." + ) + delegate_args["method"] = "POST" + delegate_args["path"] = "catalogs" + delegate_args["body"] = copy.deepcopy(body) + elif normalized == "update": + catalog_name = self._require_text(arguments, "catalog") + body = arguments.get("body") + if not isinstance(body, dict): + raise ValueError( + "Update operations require a body matching UpdateCatalogRequest." + ) + delegate_args["method"] = "PUT" + delegate_args["path"] = f"catalogs/{catalog_name}" + delegate_args["body"] = copy.deepcopy(body) + elif normalized == "delete": + catalog_name = self._require_text(arguments, "catalog") + delegate_args["method"] = "DELETE" + delegate_args["path"] = f"catalogs/{catalog_name}" + else: # pragma: no cover + raise ValueError(f"Unsupported operation: {operation}") + + raw = self._delegate.call(delegate_args) + return self._maybe_augment_error(raw, normalized) + + def _maybe_augment_error(self, result: ToolExecutionResult, operation: str) -> ToolExecutionResult: + if not result.is_error: + return result + metadata = copy.deepcopy(result.metadata) if result.metadata is not None else {} + status = int(metadata.get("response", {}).get("status", -1)) + if status not in (400, 409): + return result + + hint: Optional[str] = None + if operation == "create": + hint = ( + "Create requests must include catalog configuration in the body. " + "See CreateCatalogRequest in spec/polaris-management-service.yml." + ) + elif operation == "update": + hint = ( + "Update requests require the catalog name in the path and body matching UpdateCatalogRequest. " + "Ensure currentEntityVersion matches the latest catalog version." + ) + + if not hint: + return result + + metadata["hint"] = hint + text = result.text + if hint not in text: + text = f"{text}\nHint: {hint}" + return ToolExecutionResult(text=text, is_error=True, metadata=metadata) + + def _normalize_operation(self, operation: str) -> str: + if operation in self.LIST_ALIASES: + return "list" + if operation in self.GET_ALIASES: + return "get" + if operation in self.CREATE_ALIASES: + return "create" + if operation in self.UPDATE_ALIASES: + return "update" + if operation in self.DELETE_ALIASES: + return "delete" + raise ValueError(f"Unsupported operation: {operation}") + + def _copy_if_object(self, source: Any, target: JSONDict, field: str) -> None: + if isinstance(source, dict): + target[field] = copy.deepcopy(source) + + def _require_text(self, node: Dict[str, Any], field: str) -> str: + value = node.get(field) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"Missing required field: {field}") + return value.strip() diff --git a/client/python-mcp/polaris_mcp/tools/catalog_role.py b/client/python-mcp/polaris_mcp/tools/catalog_role.py new file mode 100644 index 0000000000..5731014394 --- /dev/null +++ b/client/python-mcp/polaris_mcp/tools/catalog_role.py @@ -0,0 +1,245 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# 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. +# + +"""Catalog role MCP tool.""" + +from __future__ import annotations + +import copy +from typing import Any, Dict, Optional, Set + +import urllib3 + +from ..authorization import AuthorizationProvider +from ..base import JSONDict, McpTool, ToolExecutionResult +from ..rest import PolarisRestTool + + +class PolarisCatalogRoleTool(McpTool): + """Manage catalog roles and grants via the Polaris management API.""" + + TOOL_NAME = "polaris-catalog-role-request" + TOOL_DESCRIPTION = "Manage catalog roles and grants via the Polaris management API." + + LIST_ALIASES: Set[str] = {"list"} + CREATE_ALIASES: Set[str] = {"create"} + GET_ALIASES: Set[str] = {"get"} + UPDATE_ALIASES: Set[str] = {"update"} + DELETE_ALIASES: Set[str] = {"delete", "remove"} + LIST_PRINCIPAL_ROLES_ALIASES: Set[str] = {"list-principal-roles", "list-assigned-principal-roles"} + LIST_GRANTS_ALIASES: Set[str] = {"list-grants"} + 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, + ) + + @property + def name(self) -> str: + return self.TOOL_NAME + + @property + def description(self) -> str: + return self.TOOL_DESCRIPTION + + def input_schema(self) -> JSONDict: + return { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": [ + "list", + "create", + "get", + "update", + "delete", + "list-principal-roles", + "list-grants", + "add-grant", + "revoke-grant", + ], + "description": ( + "Catalog role operation (list, get, create, update, delete, list-principal-roles, " + "list-grants, add-grant, revoke-grant)." + ), + }, + "catalog": { + "type": "string", + "description": "Catalog name (required).", + }, + "catalogRole": { + "type": "string", + "description": "Catalog role name for role-specific operations.", + }, + "query": { + "type": "object", + "description": "Optional query string parameters.", + "additionalProperties": {"type": "string"}, + }, + "headers": { + "type": "object", + "description": "Optional request headers.", + "additionalProperties": {"type": "string"}, + }, + "body": { + "type": ["object", "null"], + "description": ( + "Optional request body for create/update and grant operations. " + "See polaris-management-service.yml for schemas like CreateCatalogRoleRequest, AddGrantRequest." + ), + }, + }, + "required": ["operation", "catalog"], + } + + def call(self, arguments: Any) -> ToolExecutionResult: + if not isinstance(arguments, dict): + raise ValueError("Tool arguments must be a JSON object.") + + operation = self._require_text(arguments, "operation").lower().strip() + normalized = self._normalize_operation(operation) + + catalog = self._require_text(arguments, "catalog") + delegate_args: JSONDict = {} + self._copy_if_object(arguments.get("query"), delegate_args, "query") + self._copy_if_object(arguments.get("headers"), delegate_args, "headers") + + base_path = self._catalog_roles_base(catalog) + + if normalized == "list": + delegate_args["method"] = "GET" + delegate_args["path"] = base_path + elif normalized == "create": + delegate_args["method"] = "POST" + delegate_args["path"] = base_path + delegate_args["body"] = self._require_object(arguments, "body", "CreateCatalogRoleRequest") + elif normalized == "get": + delegate_args["method"] = "GET" + delegate_args["path"] = self._catalog_role_path(base_path, arguments) + elif normalized == "update": + delegate_args["method"] = "PUT" + delegate_args["path"] = self._catalog_role_path(base_path, arguments) + delegate_args["body"] = self._require_object(arguments, "body", "UpdateCatalogRoleRequest") + elif normalized == "delete": + delegate_args["method"] = "DELETE" + delegate_args["path"] = self._catalog_role_path(base_path, arguments) + elif normalized == "list-principal-roles": + delegate_args["method"] = "GET" + delegate_args["path"] = f"{self._catalog_role_path(base_path, arguments)}/principal-roles" + elif normalized == "list-grants": + delegate_args["method"] = "GET" + delegate_args["path"] = f"{self._catalog_role_path(base_path, arguments)}/grants" + elif normalized == "add-grant": + delegate_args["method"] = "PUT" + delegate_args["path"] = f"{self._catalog_role_path(base_path, arguments)}/grants" + delegate_args["body"] = self._require_object(arguments, "body", "AddGrantRequest") + elif normalized == "revoke-grant": + delegate_args["method"] = "POST" + delegate_args["path"] = f"{self._catalog_role_path(base_path, arguments)}/grants" + if isinstance(arguments.get("body"), dict): + delegate_args["body"] = copy.deepcopy(arguments["body"]) + else: # pragma: no cover + raise ValueError(f"Unsupported operation: {operation}") + + raw = self._delegate.call(delegate_args) + return self._maybe_augment_error(raw, normalized) + + def _catalog_roles_base(self, catalog: str) -> str: + return f"catalogs/{catalog}/catalog-roles" + + def _catalog_role_path(self, base_path: str, arguments: Dict[str, Any]) -> str: + role = self._require_text(arguments, "catalogRole") + return f"{base_path}/{role}" + + def _require_object(self, arguments: Dict[str, Any], field: str, description: str) -> Dict[str, Any]: + node = arguments.get(field) + if not isinstance(node, dict): + raise ValueError(f"{description} payload (`{field}`) is required.") + return copy.deepcopy(node) + + def _maybe_augment_error(self, result: ToolExecutionResult, operation: str) -> ToolExecutionResult: + if not result.is_error: + return result + metadata = copy.deepcopy(result.metadata) if result.metadata is not None else {} + status = int(metadata.get("response", {}).get("status", -1)) + if status not in (400, 409): + return result + + hint: Optional[str] = None + if operation == "create": + hint = "Create catalog role requires CreateCatalogRoleRequest body." + elif operation == "update": + hint = ( + "Update catalog role requires UpdateCatalogRoleRequest body with currentEntityVersion." + ) + elif operation == "add-grant": + hint = "Grant operations require AddGrantRequest body." + + if not hint: + return result + + metadata["hint"] = hint + text = result.text + if hint not in text: + text = f"{text}\nHint: {hint}" + return ToolExecutionResult(text=text, is_error=True, metadata=metadata) + + def _normalize_operation(self, operation: str) -> str: + if operation in self.LIST_ALIASES: + return "list" + if operation in self.CREATE_ALIASES: + return "create" + if operation in self.GET_ALIASES: + return "get" + if operation in self.UPDATE_ALIASES: + return "update" + if operation in self.DELETE_ALIASES: + return "delete" + if operation in self.LIST_PRINCIPAL_ROLES_ALIASES: + return "list-principal-roles" + if operation in self.LIST_GRANTS_ALIASES: + return "list-grants" + if operation in self.ADD_GRANT_ALIASES: + return "add-grant" + if operation in self.REVOKE_GRANT_ALIASES: + return "revoke-grant" + raise ValueError(f"Unsupported operation: {operation}") + + def _copy_if_object(self, source: Any, target: JSONDict, field: str) -> None: + if isinstance(source, dict): + target[field] = copy.deepcopy(source) + + def _require_text(self, node: Dict[str, Any], field: str) -> str: + value = node.get(field) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"Missing required field: {field}") + return value.strip() diff --git a/client/python-mcp/polaris_mcp/tools/namespace.py b/client/python-mcp/polaris_mcp/tools/namespace.py new file mode 100644 index 0000000000..c2e7e586a4 --- /dev/null +++ b/client/python-mcp/polaris_mcp/tools/namespace.py @@ -0,0 +1,303 @@ +# +# 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. +# + +"""Namespace MCP tool.""" + +from __future__ import annotations + +import copy +from typing import Any, Dict, List, Optional, Set +from urllib.parse import quote + +import urllib3 + +from ..authorization import AuthorizationProvider +from ..base import JSONDict, McpTool, ToolExecutionResult +from ..rest import PolarisRestTool + + +class PolarisNamespaceTool(McpTool): + """Manage namespaces through the Polaris REST API.""" + + TOOL_NAME = "polaris-namespace-request" + TOOL_DESCRIPTION = ( + "Manage namespaces in an Iceberg catalog (list, get, create, update properties, delete)." + ) + + LIST_ALIASES: Set[str] = {"list"} + GET_ALIASES: Set[str] = {"get", "load"} + EXISTS_ALIASES: Set[str] = {"exists", "head"} + CREATE_ALIASES: Set[str] = {"create"} + UPDATE_PROPS_ALIASES: Set[str] = {"update-properties", "set-properties", "properties-update"} + 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, + ) + + @property + def name(self) -> str: + return self.TOOL_NAME + + @property + def description(self) -> str: + return self.TOOL_DESCRIPTION + + def input_schema(self) -> JSONDict: + return { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": [ + "list", + "get", + "exists", + "create", + "update-properties", + "get-properties", + "delete", + ], + "description": ( + "Namespace operation to execute. Supported values: list, get, exists, create, " + "update-properties, get-properties, delete." + ), + }, + "catalog": { + "type": "string", + "description": "Catalog identifier (maps to the {prefix} path segment).", + }, + "namespace": { + "anyOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}}, + ], + "description": ( + "Namespace identifier. Provide as dot-delimited string (e.g. \"analytics.daily\") " + "or array of path components." + ), + }, + "query": { + "type": "object", + "description": "Optional query string parameters (for example page-size, page-token).", + "additionalProperties": {"type": "string"}, + }, + "headers": { + "type": "object", + "description": "Optional request headers.", + "additionalProperties": {"type": "string"}, + }, + "body": { + "type": "object", + "description": ( + "Optional request body payload (required for create and update-properties). " + "See the Iceberg REST catalog specification for the expected schema." + ), + }, + }, + "required": ["operation", "catalog"], + } + + def call(self, arguments: Any) -> ToolExecutionResult: + if not isinstance(arguments, dict): + raise ValueError("Tool arguments must be a JSON object.") + + operation = self._require_text(arguments, "operation").lower().strip() + normalized = self._normalize_operation(operation) + + catalog = self._encode_segment(self._require_text(arguments, "catalog")) + delegate_args: JSONDict = {} + self._copy_if_object(arguments.get("query"), delegate_args, "query") + self._copy_if_object(arguments.get("headers"), delegate_args, "headers") + + if normalized == "list": + self._handle_list(delegate_args, catalog) + elif normalized == "get": + self._handle_get(arguments, delegate_args, catalog) + elif normalized == "exists": + self._handle_exists(arguments, delegate_args, catalog) + elif normalized == "create": + self._handle_create(arguments, delegate_args, catalog) + elif normalized == "update-properties": + self._handle_update_properties(arguments, delegate_args, catalog) + elif normalized == "get-properties": + self._handle_get_properties(arguments, delegate_args, catalog) + elif normalized == "delete": + self._handle_delete(arguments, delegate_args, catalog) + else: # pragma: no cover - normalize guarantees cases + raise ValueError(f"Unsupported operation: {operation}") + + raw = self._delegate.call(delegate_args) + return self._maybe_augment_error(raw, normalized) + + def _handle_list(self, delegate_args: JSONDict, catalog: str) -> None: + delegate_args["method"] = "GET" + delegate_args["path"] = f"{catalog}/namespaces" + + def _handle_get(self, arguments: Dict[str, Any], delegate_args: JSONDict, catalog: str) -> None: + namespace = self._resolve_namespace_path(arguments) + delegate_args["method"] = "GET" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}" + + def _handle_exists( + self, arguments: Dict[str, Any], delegate_args: JSONDict, catalog: str + ) -> None: + namespace = self._resolve_namespace_path(arguments) + delegate_args["method"] = "HEAD" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}" + + def _handle_create( + self, arguments: Dict[str, Any], delegate_args: JSONDict, catalog: str + ) -> None: + body = arguments.get("body") + body_obj = copy.deepcopy(body) if isinstance(body, dict) else {} + + if "namespace" not in body_obj or body_obj.get("namespace") is None: + if "namespace" in arguments and arguments["namespace"] is not None: + namespace_parts = self._resolve_namespace_array(arguments) + body_obj["namespace"] = namespace_parts + else: + raise ValueError( + "Create operations require `body.namespace` or the `namespace` argument." + ) + + delegate_args["method"] = "POST" + delegate_args["path"] = f"{catalog}/namespaces" + delegate_args["body"] = body_obj + + def _handle_update_properties( + self, arguments: Dict[str, Any], delegate_args: JSONDict, catalog: str + ) -> None: + namespace = self._resolve_namespace_path(arguments) + body = arguments.get("body") + if not isinstance(body, dict): + raise ValueError( + "update-properties requires a body matching UpdateNamespacePropertiesRequest." + ) + delegate_args["method"] = "POST" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}/properties" + delegate_args["body"] = copy.deepcopy(body) + + def _handle_get_properties( + self, arguments: Dict[str, Any], delegate_args: JSONDict, catalog: str + ) -> None: + namespace = self._resolve_namespace_path(arguments) + delegate_args["method"] = "GET" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}/properties" + + def _handle_delete( + self, arguments: Dict[str, Any], delegate_args: JSONDict, catalog: str + ) -> None: + namespace = self._resolve_namespace_path(arguments) + delegate_args["method"] = "DELETE" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}" + + def _maybe_augment_error(self, result: ToolExecutionResult, operation: str) -> ToolExecutionResult: + if not result.is_error: + return result + + metadata = copy.deepcopy(result.metadata) if result.metadata is not None else {} + status = int(metadata.get("response", {}).get("status", -1)) + if status not in (400, 422): + return result + + hint: Optional[str] = None + if operation == "create": + hint = ( + "Create requests must include `namespace` (array of strings) in the body and optional `properties`. " + "See CreateNamespaceRequest in spec/iceberg-rest-catalog-open-api.yaml." + ) + elif operation == "update-properties": + hint = ( + "update-properties requests require `body` with `updates` and/or `removals`. " + "See UpdateNamespacePropertiesRequest in spec/iceberg-rest-catalog-open-api.yaml." + ) + + if not hint: + return result + + metadata["hint"] = hint + text = result.text + if hint not in text: + text = f"{text}\nHint: {hint}" + return ToolExecutionResult(text=text, is_error=True, metadata=metadata) + + def _resolve_namespace_array(self, arguments: Dict[str, Any]) -> List[str]: + namespace = arguments.get("namespace") + if namespace is None: + raise ValueError("Namespace must be provided.") + if isinstance(namespace, list): + if not namespace: + raise ValueError("Namespace array must contain at least one component.") + parts: List[str] = [] + for element in namespace: + if not isinstance(element, str) or not element.strip(): + raise ValueError("Namespace array elements must be non-empty strings.") + parts.append(element.strip()) + return parts + if not isinstance(namespace, str) or not namespace.strip(): + raise ValueError("Namespace must be a non-empty string.") + return namespace.strip().split(".") + + def _resolve_namespace_path(self, arguments: Dict[str, Any]) -> str: + parts = self._resolve_namespace_array(arguments) + joined = ".".join(parts) + return self._encode_segment(joined) + + def _copy_if_object(self, source: Any, target: JSONDict, field: str) -> None: + if isinstance(source, dict): + target[field] = copy.deepcopy(source) + + def _normalize_operation(self, operation: str) -> str: + if operation in self.LIST_ALIASES: + return "list" + if operation in self.GET_ALIASES: + return "get" + if operation in self.EXISTS_ALIASES: + return "exists" + if operation in self.CREATE_ALIASES: + return "create" + if operation in self.UPDATE_PROPS_ALIASES: + return "update-properties" + if operation in self.GET_PROPS_ALIASES: + return "get-properties" + if operation in self.DELETE_ALIASES: + return "delete" + raise ValueError(f"Unsupported operation: {operation}") + + def _require_text(self, node: Dict[str, Any], field: str) -> str: + value = node.get(field) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"Missing required field: {field}") + return value.strip() + + def _encode_segment(self, value: str) -> str: + return quote(value, safe="").replace("+", "%20") diff --git a/client/python-mcp/polaris_mcp/tools/policy.py b/client/python-mcp/polaris_mcp/tools/policy.py new file mode 100644 index 0000000000..14338ccd71 --- /dev/null +++ b/client/python-mcp/polaris_mcp/tools/policy.py @@ -0,0 +1,377 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# 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. +# + +"""Policy MCP tool.""" + +from __future__ import annotations + +import copy +from typing import Any, Dict, Optional, Set +from urllib.parse import quote + +import urllib3 + +from ..authorization import AuthorizationProvider +from ..base import JSONDict, McpTool, ToolExecutionResult +from ..rest import PolarisRestTool + + +class PolarisPolicyTool(McpTool): + """Expose Polaris policy endpoints via MCP.""" + + TOOL_NAME = "polaris-policy" + TOOL_DESCRIPTION = ( + "Manage Polaris policies (list, create, update, delete, attach, detach, applicable)." + ) + + LIST_ALIASES: Set[str] = {"list"} + GET_ALIASES: Set[str] = {"get", "load", "fetch"} + CREATE_ALIASES: Set[str] = {"create"} + UPDATE_ALIASES: Set[str] = {"update"} + DELETE_ALIASES: Set[str] = {"delete", "drop", "remove"} + ATTACH_ALIASES: Set[str] = {"attach", "map"} + 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, + ) + + @property + def name(self) -> str: + return self.TOOL_NAME + + @property + def description(self) -> str: + return self.TOOL_DESCRIPTION + + def input_schema(self) -> JSONDict: + return { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["list", "get", "create", "update", "delete", "attach", "detach", "applicable"], + "description": ( + "Policy operation to execute. Supported values: list, get, create, update, delete, attach, detach, applicable." + ), + }, + "catalog": { + "type": "string", + "description": "Polaris catalog identifier (maps to the {prefix} path segment).", + }, + "namespace": { + "anyOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}}, + ], + "description": ( + "Namespace that contains the target policies. Provide as a dot-delimited string " + '(e.g. "analytics.daily") or an array of strings.' + ), + }, + "policy": { + "type": "string", + "description": "Policy identifier for operations that target a specific policy.", + }, + "query": { + "type": "object", + "description": ( + "Optional query string parameters (for example page-size, policy-type, detach-all)." + ), + "additionalProperties": {"type": "string"}, + }, + "headers": { + "type": "object", + "description": "Optional additional HTTP headers to include with the request.", + "additionalProperties": {"type": "string"}, + }, + "body": { + "type": "object", + "description": ( + "Optional request body payload for create/update/attach/detach operations. " + "The structure must follow the corresponding Polaris REST schema." + ), + }, + }, + "required": ["operation", "catalog"], + } + + def call(self, arguments: Any) -> ToolExecutionResult: + if not isinstance(arguments, dict): + raise ValueError("Tool arguments must be a JSON object.") + + operation = self._require_text(arguments, "operation").lower().strip() + normalized = self._normalize_operation(operation) + + catalog = self._encode_segment(self._require_text(arguments, "catalog")) + namespace: Optional[str] = None + if normalized != "applicable": + namespace = self._encode_segment(self._resolve_namespace(arguments.get("namespace"))) + elif arguments.get("namespace") is not None: + namespace = self._encode_segment(self._resolve_namespace(arguments.get("namespace"))) + + delegate_args: JSONDict = {} + self._copy_if_object(arguments.get("query"), delegate_args, "query") + self._copy_if_object(arguments.get("headers"), delegate_args, "headers") + + if normalized == "list": + self._require_namespace(namespace, "list") + self._handle_list(delegate_args, catalog, namespace) + elif normalized == "get": + self._require_namespace(namespace, "get") + self._handle_get(arguments, delegate_args, catalog, namespace) + elif normalized == "create": + self._require_namespace(namespace, "create") + self._handle_create(arguments, delegate_args, catalog, namespace) + elif normalized == "update": + self._require_namespace(namespace, "update") + self._handle_update(arguments, delegate_args, catalog, namespace) + elif normalized == "delete": + self._require_namespace(namespace, "delete") + self._handle_delete(arguments, delegate_args, catalog, namespace) + elif normalized == "attach": + self._require_namespace(namespace, "attach") + self._handle_attach(arguments, delegate_args, catalog, namespace) + elif normalized == "detach": + self._require_namespace(namespace, "detach") + self._handle_detach(arguments, delegate_args, catalog, namespace) + elif normalized == "applicable": + self._handle_applicable(delegate_args, catalog) + else: # pragma: no cover + raise ValueError(f"Unsupported operation: {operation}") + + raw = self._delegate.call(delegate_args) + return self._maybe_augment_error(raw, normalized) + + def _handle_list(self, delegate_args: JSONDict, catalog: str, namespace: str) -> None: + delegate_args["method"] = "GET" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}/policies" + + def _handle_get( + self, + arguments: Dict[str, Any], + delegate_args: JSONDict, + catalog: str, + namespace: str, + ) -> None: + policy = self._encode_segment( + self._require_text(arguments, "policy", "Policy name is required for get operations.") + ) + delegate_args["method"] = "GET" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}/policies/{policy}" + + def _handle_create( + self, + arguments: Dict[str, Any], + delegate_args: JSONDict, + catalog: str, + namespace: str, + ) -> None: + body = arguments.get("body") + if not isinstance(body, dict): + raise ValueError( + "Create operations require a request body that matches the CreatePolicyRequest schema." + ) + delegate_args["method"] = "POST" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}/policies" + delegate_args["body"] = copy.deepcopy(body) + + def _handle_update( + self, + arguments: Dict[str, Any], + delegate_args: JSONDict, + catalog: str, + namespace: str, + ) -> None: + body = arguments.get("body") + if not isinstance(body, dict): + raise ValueError( + "Update operations require a request body that matches the UpdatePolicyRequest schema." + ) + policy = self._encode_segment( + self._require_text(arguments, "policy", "Policy name is required for update operations.") + ) + delegate_args["method"] = "PUT" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}/policies/{policy}" + delegate_args["body"] = copy.deepcopy(body) + + def _handle_delete( + self, + arguments: Dict[str, Any], + delegate_args: JSONDict, + catalog: str, + namespace: str, + ) -> None: + policy = self._encode_segment( + self._require_text(arguments, "policy", "Policy name is required for delete operations.") + ) + delegate_args["method"] = "DELETE" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}/policies/{policy}" + + def _handle_attach( + self, + arguments: Dict[str, Any], + delegate_args: JSONDict, + catalog: str, + namespace: str, + ) -> None: + body = arguments.get("body") + if not isinstance(body, dict): + raise ValueError( + "Attach operations require a request body that matches the AttachPolicyRequest schema." + ) + policy = self._encode_segment( + self._require_text(arguments, "policy", "Policy name is required for attach operations.") + ) + delegate_args["method"] = "PUT" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}/policies/{policy}/mappings" + delegate_args["body"] = copy.deepcopy(body) + + def _handle_detach( + self, + arguments: Dict[str, Any], + delegate_args: JSONDict, + catalog: str, + namespace: str, + ) -> None: + body = arguments.get("body") + if not isinstance(body, dict): + raise ValueError( + "Detach operations require a request body that matches the DetachPolicyRequest schema." + ) + policy = self._encode_segment( + self._require_text(arguments, "policy", "Policy name is required for detach operations.") + ) + delegate_args["method"] = "POST" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}/policies/{policy}/mappings" + delegate_args["body"] = copy.deepcopy(body) + + def _handle_applicable(self, delegate_args: JSONDict, catalog: str) -> None: + delegate_args["method"] = "GET" + delegate_args["path"] = f"{catalog}/applicable-policies" + + def _maybe_augment_error(self, result: ToolExecutionResult, operation: str) -> ToolExecutionResult: + if not result.is_error: + return result + metadata = copy.deepcopy(result.metadata) if result.metadata is not None else {} + status = int(metadata.get("response", {}).get("status", -1)) + if status not in (400, 404, 422): + return result + + hint: Optional[str] = None + if operation == "create": + hint = ( + "Create requests must include `name`, `type`, and optional `description`/`content` in the body. " + "See CreatePolicyRequest in spec/polaris-catalog-apis/policy-apis.yaml. " + "Common types include system.data-compaction, system.metadata-compaction, " + "system.orphan-file-removal, and system.snapshot-expiry. " + "Example: {\"name\":\"weekly_compaction\",\"type\":\"system.data-compaction\",\"content\":{...}}. " + "Reference schema: http://polaris.apache.org/schemas/policies/system/data-compaction/2025-02-03.json" + ) + elif operation == "update": + hint = ( + "Update requests require the policy name in the path and the body with `description`, " + "`content`, and `currentVersion`." + ) + elif operation == "attach": + hint = ( + "Attach requests require a body with `targetType`, `targetName`, and optional `parameters`. " + "Ensure the policy exists first (create it with operation=create) before attaching." + ) + elif operation == "detach": + hint = ( + "Detach requests require a body with `targetType`, `targetName`, and optional `parameters`." + ) + + if not hint: + return result + + metadata["hint"] = hint + text = result.text + if hint not in text: + text = f"{text}\nHint: {hint}" + return ToolExecutionResult(text=text, is_error=True, metadata=metadata) + + def _normalize_operation(self, operation: str) -> str: + if operation in self.LIST_ALIASES: + return "list" + if operation in self.GET_ALIASES: + return "get" + if operation in self.CREATE_ALIASES: + return "create" + if operation in self.UPDATE_ALIASES: + return "update" + if operation in self.DELETE_ALIASES: + return "delete" + if operation in self.ATTACH_ALIASES: + return "attach" + if operation in self.DETACH_ALIASES: + return "detach" + if operation in self.APPLICABLE_ALIASES: + return "applicable" + raise ValueError(f"Unsupported operation: {operation}") + + def _copy_if_object(self, source: Any, target: JSONDict, field: str) -> None: + if isinstance(source, dict): + target[field] = copy.deepcopy(source) + + def _require_text(self, node: Dict[str, Any], field: str, message: Optional[str] = None) -> str: + value = node.get(field) + if not isinstance(value, str) or not value.strip(): + if message is None: + message = f"Missing required field: {field}" + raise ValueError(message) + return value.strip() + + def _require_namespace(self, namespace: Optional[str], operation: str) -> None: + if not namespace: + raise ValueError( + f"Namespace is required for {operation} operations. Provide `namespace` as a string or array." + ) + + def _resolve_namespace(self, namespace: Any) -> str: + if namespace is None: + raise ValueError("Namespace must be provided.") + if isinstance(namespace, list): + if not namespace: + raise ValueError("Namespace array must contain at least one element.") + parts = [] + for element in namespace: + if not isinstance(element, str) or not element.strip(): + raise ValueError("Namespace array elements must be non-empty strings.") + parts.append(element.strip()) + return ".".join(parts) + if not isinstance(namespace, str) or not namespace.strip(): + raise ValueError("Namespace must be a non-empty string.") + return namespace.strip() + + def _encode_segment(self, value: str) -> str: + return quote(value, safe="").replace("+", "%20") diff --git a/client/python-mcp/polaris_mcp/tools/principal.py b/client/python-mcp/polaris_mcp/tools/principal.py new file mode 100644 index 0000000000..a94585ba83 --- /dev/null +++ b/client/python-mcp/polaris_mcp/tools/principal.py @@ -0,0 +1,295 @@ +# +# 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. +# + +"""Principal MCP tool.""" + +from __future__ import annotations + +import copy +from typing import Any, Dict, Optional, Set + +import urllib3 + +from ..authorization import AuthorizationProvider +from ..base import JSONDict, McpTool, ToolExecutionResult +from ..rest import PolarisRestTool + + +class PolarisPrincipalTool(McpTool): + """Manage principals via the Polaris management API.""" + + TOOL_NAME = "polaris-principal-request" + TOOL_DESCRIPTION = ( + "Manage principals via the Polaris management API (list, get, create, update, delete, " + "rotate/reset credentials, role assignment)." + ) + + LIST_ALIASES: Set[str] = {"list"} + CREATE_ALIASES: Set[str] = {"create"} + GET_ALIASES: Set[str] = {"get"} + UPDATE_ALIASES: Set[str] = {"update"} + DELETE_ALIASES: Set[str] = {"delete", "remove"} + ROTATE_ALIASES: Set[str] = {"rotate-credentials", "rotate"} + RESET_ALIASES: Set[str] = {"reset-credentials", "reset"} + LIST_ROLES_ALIASES: Set[str] = {"list-principal-roles", "list-roles"} + 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, + ) + + @property + def name(self) -> str: + return self.TOOL_NAME + + @property + def description(self) -> str: + return self.TOOL_DESCRIPTION + + def input_schema(self) -> JSONDict: + return { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": [ + "list", + "create", + "get", + "update", + "delete", + "rotate-credentials", + "reset-credentials", + "list-principal-roles", + "assign-principal-role", + "revoke-principal-role", + ], + "description": ( + "Principal operation to execute (list, get, create, update, delete, rotate-credentials, " + "reset-credentials, list-principal-roles, assign-principal-role, revoke-principal-role). " + "Optional query parameters (e.g. catalog context) can be supplied via `query`." + ), + }, + "principal": { + "type": "string", + "description": "Principal name for operations targeting a specific principal.", + }, + "principalRole": { + "type": "string", + "description": "Principal role name for assignment/revocation operations.", + }, + "query": { + "type": "object", + "description": "Optional query string parameters.", + "additionalProperties": {"type": "string"}, + }, + "headers": { + "type": "object", + "description": "Optional request headers.", + "additionalProperties": {"type": "string"}, + }, + "body": { + "type": ["object", "null"], + "description": ( + "Optional request body payload. Required for create/update, grant/revoke operations. " + "See polaris-management-service.yml for schemas such as CreatePrincipalRequest." + ), + }, + }, + "required": ["operation"], + } + + def call(self, arguments: Any) -> ToolExecutionResult: + if not isinstance(arguments, dict): + raise ValueError("Tool arguments must be a JSON object.") + + operation = self._require_text(arguments, "operation").lower().strip() + normalized = self._normalize_operation(operation) + + delegate_args: JSONDict = {} + self._copy_if_object(arguments.get("query"), delegate_args, "query") + self._copy_if_object(arguments.get("headers"), delegate_args, "headers") + + if normalized == "list": + self._handle_list(delegate_args) + elif normalized == "create": + self._handle_create(arguments, delegate_args) + elif normalized == "get": + self._handle_get(arguments, delegate_args) + elif normalized == "update": + self._handle_update(arguments, delegate_args) + elif normalized == "delete": + self._handle_delete(arguments, delegate_args) + elif normalized == "rotate-credentials": + self._handle_rotate(arguments, delegate_args) + elif normalized == "reset-credentials": + self._handle_reset(arguments, delegate_args) + elif normalized == "list-principal-roles": + self._handle_list_roles(arguments, delegate_args) + elif normalized == "assign-principal-role": + self._handle_assign_role(arguments, delegate_args) + elif normalized == "revoke-principal-role": + self._handle_revoke_role(arguments, delegate_args) + else: # pragma: no cover + raise ValueError(f"Unsupported operation: {operation}") + + raw = self._delegate.call(delegate_args) + return self._maybe_augment_error(raw, normalized) + + def _handle_list(self, delegate_args: JSONDict) -> None: + delegate_args["method"] = "GET" + delegate_args["path"] = "principals" + + def _handle_create(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: + body = arguments.get("body") + if not isinstance(body, dict): + raise ValueError("Create principal requires a body matching CreatePrincipalRequest.") + delegate_args["method"] = "POST" + delegate_args["path"] = "principals" + delegate_args["body"] = copy.deepcopy(body) + + def _handle_get(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: + principal = self._require_text(arguments, "principal") + delegate_args["method"] = "GET" + delegate_args["path"] = f"principals/{principal}" + + def _handle_update(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: + principal = self._require_text(arguments, "principal") + body = arguments.get("body") + if not isinstance(body, dict): + raise ValueError("Update principal requires a body matching UpdatePrincipalRequest.") + delegate_args["method"] = "PUT" + delegate_args["path"] = f"principals/{principal}" + delegate_args["body"] = copy.deepcopy(body) + + def _handle_delete(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: + principal = self._require_text(arguments, "principal") + delegate_args["method"] = "DELETE" + delegate_args["path"] = f"principals/{principal}" + + def _handle_rotate(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: + principal = self._require_text(arguments, "principal") + delegate_args["method"] = "POST" + delegate_args["path"] = f"principals/{principal}/rotate" + + def _handle_reset(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: + principal = self._require_text(arguments, "principal") + delegate_args["method"] = "POST" + delegate_args["path"] = f"principals/{principal}/reset" + if isinstance(arguments.get("body"), dict): + delegate_args["body"] = copy.deepcopy(arguments["body"]) + + def _handle_list_roles(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: + principal = self._require_text(arguments, "principal") + delegate_args["method"] = "GET" + delegate_args["path"] = f"principals/{principal}/principal-roles" + + def _handle_assign_role(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: + principal = self._require_text(arguments, "principal") + body = arguments.get("body") + if not isinstance(body, dict): + raise ValueError( + "assign-principal-role requires a body matching GrantPrincipalRoleRequest." + ) + delegate_args["method"] = "PUT" + delegate_args["path"] = f"principals/{principal}/principal-roles" + delegate_args["body"] = copy.deepcopy(body) + + def _handle_revoke_role(self, arguments: Dict[str, Any], delegate_args: JSONDict) -> None: + principal = self._require_text(arguments, "principal") + role = self._require_text(arguments, "principalRole") + delegate_args["method"] = "DELETE" + delegate_args["path"] = f"principals/{principal}/principal-roles/{role}" + + def _maybe_augment_error(self, result: ToolExecutionResult, operation: str) -> ToolExecutionResult: + if not result.is_error: + return result + metadata = copy.deepcopy(result.metadata) if result.metadata is not None else {} + status = int(metadata.get("response", {}).get("status", -1)) + if status not in (400, 409): + return result + + hint: Optional[str] = None + if operation == "create": + hint = ( + "Create principal requires a body matching CreatePrincipalRequest. " + "See spec/polaris-management-service.yml." + ) + elif operation == "update": + hint = ( + "Update principal requires `principal` and body matching UpdatePrincipalRequest with currentEntityVersion." + ) + elif operation == "assign-principal-role": + hint = ( + "Provide GrantPrincipalRoleRequest in the body (principalRoleName, catalogName, etc.)." + ) + + if not hint: + return result + + metadata["hint"] = hint + text = result.text + if hint not in text: + text = f"{text}\nHint: {hint}" + return ToolExecutionResult(text=text, is_error=True, metadata=metadata) + + def _normalize_operation(self, operation: str) -> str: + if operation in self.LIST_ALIASES: + return "list" + if operation in self.CREATE_ALIASES: + return "create" + if operation in self.GET_ALIASES: + return "get" + if operation in self.UPDATE_ALIASES: + return "update" + if operation in self.DELETE_ALIASES: + return "delete" + if operation in self.ROTATE_ALIASES: + return "rotate-credentials" + if operation in self.RESET_ALIASES: + return "reset-credentials" + if operation in self.LIST_ROLES_ALIASES: + return "list-principal-roles" + if operation in self.ASSIGN_ROLE_ALIASES: + return "assign-principal-role" + if operation in self.REVOKE_ROLE_ALIASES: + return "revoke-principal-role" + raise ValueError(f"Unsupported operation: {operation}") + + def _copy_if_object(self, source: Any, target: JSONDict, field: str) -> None: + if isinstance(source, dict): + target[field] = copy.deepcopy(source) + + def _require_text(self, node: Dict[str, Any], field: str) -> str: + value = node.get(field) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"Missing required field: {field}") + return value.strip() diff --git a/client/python-mcp/polaris_mcp/tools/principal_role.py b/client/python-mcp/polaris_mcp/tools/principal_role.py new file mode 100644 index 0000000000..47d7cfcb73 --- /dev/null +++ b/client/python-mcp/polaris_mcp/tools/principal_role.py @@ -0,0 +1,255 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# 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. +# + +"""Principal role MCP tool.""" + +from __future__ import annotations + +import copy +from typing import Any, Dict, Optional, Set + +import urllib3 + +from ..authorization import AuthorizationProvider +from ..base import JSONDict, McpTool, ToolExecutionResult +from ..rest import PolarisRestTool + + +class PolarisPrincipalRoleTool(McpTool): + """Manage principal roles through the Polaris management API.""" + + TOOL_NAME = "polaris-principal-role-request" + TOOL_DESCRIPTION = ( + "Manage principal roles (list, get, create, update, delete) and their catalog-role assignments via the Polaris management API." + ) + + LIST_ALIASES: Set[str] = {"list"} + CREATE_ALIASES: Set[str] = {"create"} + GET_ALIASES: Set[str] = {"get"} + UPDATE_ALIASES: Set[str] = {"update"} + DELETE_ALIASES: Set[str] = {"delete", "remove"} + LIST_PRINCIPALS_ALIASES: Set[str] = {"list-principals", "list-assignees"} + LIST_CATALOG_ROLES_ALIASES: Set[str] = {"list-catalog-roles", "list-mapped-catalog-roles"} + ASSIGN_CATALOG_ROLE_ALIASES: Set[str] = {"assign-catalog-role", "grant-catalog-role"} + REVOKE_CATALOG_ROLE_ALIASES: Set[str] = {"revoke-catalog-role", "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, + ) + + @property + def name(self) -> str: + return self.TOOL_NAME + + @property + def description(self) -> str: + return self.TOOL_DESCRIPTION + + def input_schema(self) -> JSONDict: + return { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": [ + "list", + "create", + "get", + "update", + "delete", + "list-principals", + "list-catalog-roles", + "assign-catalog-role", + "revoke-catalog-role", + ], + "description": ( + "Principal role operation to execute. Provide optional `catalog`/`catalogRole` when required " + "(e.g. assignment)." + ), + }, + "principalRole": { + "type": "string", + "description": "Principal role name (required for role-specific operations).", + }, + "catalog": { + "type": "string", + "description": "Catalog name for mapping operations to catalog roles.", + }, + "catalogRole": { + "type": "string", + "description": "Catalog role name for revoke operations.", + }, + "query": { + "type": "object", + "description": "Optional query string parameters.", + "additionalProperties": {"type": "string"}, + }, + "headers": { + "type": "object", + "description": "Optional request headers.", + "additionalProperties": {"type": "string"}, + }, + "body": { + "type": ["object", "null"], + "description": ( + "Optional request body payload (required for create/update and assign operations). " + "See polaris-management-service.yml for request schemas." + ), + }, + }, + "required": ["operation"], + } + + def call(self, arguments: Any) -> ToolExecutionResult: + if not isinstance(arguments, dict): + raise ValueError("Tool arguments must be a JSON object.") + + operation = self._require_text(arguments, "operation").lower().strip() + normalized = self._normalize_operation(operation) + + delegate_args: JSONDict = {} + self._copy_if_object(arguments.get("query"), delegate_args, "query") + self._copy_if_object(arguments.get("headers"), delegate_args, "headers") + + if normalized == "list": + delegate_args["method"] = "GET" + delegate_args["path"] = "principal-roles" + elif normalized == "create": + delegate_args["method"] = "POST" + delegate_args["path"] = "principal-roles" + delegate_args["body"] = self._require_object( + arguments, "body", "CreatePrincipalRoleRequest" + ) + elif normalized == "get": + delegate_args["method"] = "GET" + delegate_args["path"] = self._principal_role_path(arguments) + elif normalized == "update": + delegate_args["method"] = "PUT" + delegate_args["path"] = self._principal_role_path(arguments) + delegate_args["body"] = self._require_object( + arguments, "body", "UpdatePrincipalRoleRequest" + ) + elif normalized == "delete": + delegate_args["method"] = "DELETE" + delegate_args["path"] = self._principal_role_path(arguments) + elif normalized == "list-principals": + delegate_args["method"] = "GET" + delegate_args["path"] = f"{self._principal_role_path(arguments)}/principals" + elif normalized == "list-catalog-roles": + delegate_args["method"] = "GET" + delegate_args["path"] = self._principal_role_catalog_path(arguments) + elif normalized == "assign-catalog-role": + delegate_args["method"] = "PUT" + delegate_args["path"] = self._principal_role_catalog_path(arguments) + delegate_args["body"] = self._require_object( + arguments, "body", "GrantCatalogRoleRequest" + ) + elif normalized == "revoke-catalog-role": + delegate_args["method"] = "DELETE" + catalog_role = self._require_text(arguments, "catalogRole") + delegate_args["path"] = ( + f"{self._principal_role_catalog_path(arguments)}/{catalog_role}" + ) + else: # pragma: no cover + raise ValueError(f"Unsupported operation: {operation}") + + raw = self._delegate.call(delegate_args) + return self._maybe_augment_error(raw, normalized) + + def _principal_role_path(self, arguments: Dict[str, Any]) -> str: + role = self._require_text(arguments, "principalRole") + return f"principal-roles/{role}" + + def _principal_role_catalog_path(self, arguments: Dict[str, Any]) -> str: + catalog = self._require_text(arguments, "catalog") + return f"{self._principal_role_path(arguments)}/catalog-roles/{catalog}" + + def _require_object(self, arguments: Dict[str, Any], field: str, description: str) -> Dict[str, Any]: + node = arguments.get(field) + if not isinstance(node, dict): + raise ValueError(f"{description} payload (`{field}`) is required.") + return copy.deepcopy(node) + + def _maybe_augment_error(self, result: ToolExecutionResult, operation: str) -> ToolExecutionResult: + if not result.is_error: + return result + metadata = copy.deepcopy(result.metadata) if result.metadata is not None else {} + status = int(metadata.get("response", {}).get("status", -1)) + if status not in (400, 409): + return result + + hint: Optional[str] = None + if operation == "create": + hint = "Create principal role requires CreatePrincipalRoleRequest body." + elif operation == "update": + hint = ( + "Update principal role requires UpdatePrincipalRoleRequest body with currentEntityVersion." + ) + elif operation == "assign-catalog-role": + hint = "Provide GrantCatalogRoleRequest body when assigning catalog roles." + + if not hint: + return result + + metadata["hint"] = hint + text = result.text + if hint not in text: + text = f"{text}\nHint: {hint}" + return ToolExecutionResult(text=text, is_error=True, metadata=metadata) + + def _normalize_operation(self, operation: str) -> str: + if operation in self.LIST_ALIASES: + return "list" + if operation in self.CREATE_ALIASES: + return "create" + if operation in self.GET_ALIASES: + return "get" + if operation in self.UPDATE_ALIASES: + return "update" + if operation in self.DELETE_ALIASES: + return "delete" + if operation in self.LIST_PRINCIPALS_ALIASES: + return "list-principals" + if operation in self.LIST_CATALOG_ROLES_ALIASES: + return "list-catalog-roles" + if operation in self.ASSIGN_CATALOG_ROLE_ALIASES: + return "assign-catalog-role" + if operation in self.REVOKE_CATALOG_ROLE_ALIASES: + return "revoke-catalog-role" + raise ValueError(f"Unsupported operation: {operation}") + + def _copy_if_object(self, source: Any, target: JSONDict, field: str) -> None: + if isinstance(source, dict): + target[field] = copy.deepcopy(source) + + def _require_text(self, node: Dict[str, Any], field: str) -> str: + value = node.get(field) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"Missing required field: {field}") + return value.strip() diff --git a/client/python-mcp/polaris_mcp/tools/table.py b/client/python-mcp/polaris_mcp/tools/table.py new file mode 100644 index 0000000000..865fdf503d --- /dev/null +++ b/client/python-mcp/polaris_mcp/tools/table.py @@ -0,0 +1,258 @@ +# +# 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. +# + +"""Iceberg table MCP tool.""" + +from __future__ import annotations + +import copy +from typing import Any, Dict, Optional, Set +from urllib.parse import quote + +import urllib3 + +from ..authorization import AuthorizationProvider +from ..base import JSONDict, McpTool, ToolExecutionResult +from ..rest import PolarisRestTool + + +class PolarisTableTool(McpTool): + """Expose Polaris table REST endpoints through MCP.""" + + TOOL_NAME = "polaris-iceberg-table" + TOOL_DESCRIPTION = ( + "Perform table-centric operations (list, get, create, commit, delete) using the Polaris REST API." + ) + + LIST_ALIASES: Set[str] = {"list", "ls"} + GET_ALIASES: Set[str] = {"get", "load", "fetch"} + CREATE_ALIASES: Set[str] = {"create"} + 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, + ) + + @property + def name(self) -> str: + return self.TOOL_NAME + + @property + def description(self) -> str: + return self.TOOL_DESCRIPTION + + def input_schema(self) -> JSONDict: + return { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["list", "get", "create", "commit", "delete"], + "description": ( + "Table operation to execute. Supported values: list, get (synonyms: load, fetch), " + "create, commit (synonym: update), delete (synonym: drop)." + ), + }, + "catalog": { + "type": "string", + "description": "Polaris catalog identifier (maps to the {prefix} path segment).", + }, + "namespace": { + "anyOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}}, + ], + "description": ( + "Namespace that contains the target tables. Provide as a dot-delimited string " + '(e.g. "analytics.daily") or an array of strings.' + ), + }, + "table": { + "type": "string", + "description": ( + "Table identifier for operations that target a specific table (get, commit, delete)." + ), + }, + "query": { + "type": "object", + "description": "Optional query string parameters (for example page-size, page-token, include-drop).", + "additionalProperties": {"type": "string"}, + }, + "headers": { + "type": "object", + "description": "Optional additional HTTP headers to include with the request.", + "additionalProperties": {"type": "string"}, + }, + "body": { + "type": "object", + "description": "Optional request body payload for create or commit operations.", + }, + }, + "required": ["operation", "catalog", "namespace"], + } + + def call(self, arguments: Any) -> ToolExecutionResult: + if not isinstance(arguments, dict): + raise ValueError("Tool arguments must be a JSON object.") + + operation = self._require_text(arguments, "operation").lower().strip() + normalized = self._normalize_operation(operation) + + catalog = self._encode_segment(self._require_text(arguments, "catalog")) + namespace = self._encode_segment(self._resolve_namespace(arguments.get("namespace"))) + + delegate_args: JSONDict = {} + self._copy_if_object(arguments.get("query"), delegate_args, "query") + self._copy_if_object(arguments.get("headers"), delegate_args, "headers") + + if normalized == "list": + self._handle_list(delegate_args, catalog, namespace) + elif normalized == "get": + self._handle_get(arguments, delegate_args, catalog, namespace) + elif normalized == "create": + self._handle_create(arguments, delegate_args, catalog, namespace) + elif normalized == "commit": + self._handle_commit(arguments, delegate_args, catalog, namespace) + elif normalized == "delete": + self._handle_delete(arguments, delegate_args, catalog, namespace) + else: # pragma: no cover - defensive, normalize guarantees handled cases + raise ValueError(f"Unsupported operation: {operation}") + + return self._delegate.call(delegate_args) + + def _handle_list(self, delegate_args: JSONDict, catalog: str, namespace: str) -> None: + delegate_args["method"] = "GET" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}/tables" + + def _handle_get( + self, + arguments: Dict[str, Any], + delegate_args: JSONDict, + catalog: str, + namespace: str, + ) -> None: + table = self._encode_segment( + self._require_text(arguments, "table", "Table name is required for get operations.") + ) + delegate_args["method"] = "GET" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}/tables/{table}" + + def _handle_create( + self, + arguments: Dict[str, Any], + delegate_args: JSONDict, + catalog: str, + namespace: str, + ) -> None: + body = arguments.get("body") + if not isinstance(body, dict): + raise ValueError( + "Create operations require a request body that matches the CreateTableRequest schema." + ) + delegate_args["method"] = "POST" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}/tables" + delegate_args["body"] = copy.deepcopy(body) + + def _handle_commit( + self, + arguments: Dict[str, Any], + delegate_args: JSONDict, + catalog: str, + namespace: str, + ) -> None: + body = arguments.get("body") + if not isinstance(body, dict): + raise ValueError( + "Commit operations require a request body that matches the CommitTableRequest schema." + ) + table = self._encode_segment( + self._require_text(arguments, "table", "Table name is required for commit operations.") + ) + delegate_args["method"] = "POST" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}/tables/{table}" + delegate_args["body"] = copy.deepcopy(body) + + def _handle_delete( + self, + arguments: Dict[str, Any], + delegate_args: JSONDict, + catalog: str, + namespace: str, + ) -> None: + table = self._encode_segment( + self._require_text(arguments, "table", "Table name is required for delete operations.") + ) + delegate_args["method"] = "DELETE" + delegate_args["path"] = f"{catalog}/namespaces/{namespace}/tables/{table}" + + def _normalize_operation(self, operation: str) -> str: + if operation in self.LIST_ALIASES: + return "list" + if operation in self.GET_ALIASES: + return "get" + if operation in self.CREATE_ALIASES: + return "create" + if operation in self.COMMIT_ALIASES: + return "commit" + if operation in self.DELETE_ALIASES: + return "delete" + raise ValueError(f"Unsupported operation: {operation}") + + def _resolve_namespace(self, namespace: Any) -> str: + if namespace is None: + raise ValueError("Namespace must be provided.") + if isinstance(namespace, list): + if not namespace: + raise ValueError("Namespace array must contain at least one element.") + parts = [] + for element in namespace: + if not isinstance(element, str) or not element.strip(): + raise ValueError("Namespace array elements must be non-empty strings.") + parts.append(element.strip()) + return ".".join(parts) + if not isinstance(namespace, str) or not namespace.strip(): + raise ValueError("Namespace must be a non-empty string.") + return namespace.strip() + + def _copy_if_object(self, source: Any, target: JSONDict, field: str) -> None: + if isinstance(source, dict): + target[field] = copy.deepcopy(source) + + def _encode_segment(self, value: str) -> str: + return quote(value, safe="").replace("+", "%20") + + def _require_text(self, node: Dict[str, Any], field: str, message: Optional[str] = None) -> str: + value = node.get(field) + if not isinstance(value, str) or not value.strip(): + if message is None: + message = f"Missing required field: {field}" + raise ValueError(message) + return value.strip() diff --git a/client/python-mcp/pyproject.toml b/client/python-mcp/pyproject.toml new file mode 100644 index 0000000000..817cb37f54 --- /dev/null +++ b/client/python-mcp/pyproject.toml @@ -0,0 +1,48 @@ +# +# 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. +# + +[project] +name = "polaris-mcp" +version = "1.2.0" +description = "Apache Polaris Model Context Protocol server" +authors = [ + {name = "Apache Software Foundation", email = "dev@polaris.apache.org"} +] +readme = "README.md" +requires-python = ">=3.10,<4.0" +license = "Apache-2.0" +keywords = ["Apache Polaris", "Polaris", "Model Context Protocol"] +dependencies = [ + "fastmcp>=2.13.0.2", + "urllib3>=1.25.3,<3.0.0", +] + +[project.scripts] +polaris-mcp = "polaris_mcp.server:main" + +[project.urls] +homepage = "https://polaris.apache.org/" +repository = "https://github.com/apache/polaris/" + +[build-system] +requires = ["setuptools>=68.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +include = ["polaris_mcp"]