Skip to content

Commit 8d3997a

Browse files
authored
Add MCP server (#39)
1 parent 601fee2 commit 8d3997a

24 files changed

+5479
-0
lines changed

.github/workflows/mcp-server.yml

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one
3+
# or more contributor license agreements. See the NOTICE file
4+
# distributed with this work for additional information
5+
# regarding copyright ownership. The ASF licenses this file
6+
# to you under the Apache License, Version 2.0 (the
7+
# "License"); you may not use this file except in compliance
8+
# with the License. You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing,
13+
# software distributed under the License is distributed on an
14+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
# KIND, either express or implied. See the License for the
16+
# specific language governing permissions and limitations
17+
# under the License.
18+
#
19+
20+
# This workflow uses actions that are not certified by GitHub.
21+
# They are provided by a third-party and are governed by
22+
# separate terms of service, privacy policy, and support
23+
# documentation.
24+
# This workflow will build a Python project with Poetry and cache/restore any dependencies to improve the workflow execution time
25+
# For more information see: https://docs.github.com/en/actions/use-cases-and-examples/building-and-testing/building-and-testing-python
26+
27+
name: MCP Server CI
28+
29+
on:
30+
push:
31+
branches: [ "main" ]
32+
pull_request:
33+
branches: [ "main" ]
34+
35+
env:
36+
GRADLE_TOS_ACCEPTED: ${{ vars.GRADLE_TOS_ACCEPTED }}
37+
DEVELOCITY_SERVER: ${{ vars.DEVELOCITY_SERVER }}
38+
DEVELOCITY_PROJECT_ID: ${{ vars.DEVELOCITY_PROJECT_ID }}
39+
40+
jobs:
41+
build:
42+
43+
runs-on: ubuntu-latest
44+
strategy:
45+
matrix:
46+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
47+
48+
steps:
49+
- name: Checkout Polaris Tools project
50+
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
51+
52+
- name: Set up Python ${{ matrix.python-version }}
53+
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6
54+
with:
55+
python-version: ${{ matrix.python-version }}
56+
57+
- name: Install uv
58+
run: |
59+
curl -LsSf https://astral.sh/uv/install.sh | sh
60+
echo "${HOME}/.local/bin" >> "${GITHUB_PATH}"
61+
62+
- name: Sync dependencies
63+
working-directory: mcp-server
64+
run: |
65+
uv sync --extra test --extra dev
66+
67+
- name: Lint
68+
working-directory: mcp-server
69+
run: |
70+
uv run pre-commit run --all-files
71+
72+
- name: Unit Tests
73+
working-directory: mcp-server
74+
run: |
75+
uv run pytest

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ version.txt
6060

6161
# Python venv
6262
venv/
63+
mcp-server/polaris_mcp.egg-info/
64+
mcp-server/polaris_mcp/__pycache__/
65+
mcp-server/polaris_mcp/tools/__pycache__/
66+
mcp-server/tests/__pycache__/
6367

6468
# Maven flatten plugin
6569
.flattened-pom.xml

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,4 @@ There are three tools:
2727
1. [Benchmarks](/benchmarks/README.md): Performance benchmarks for Polaris.
2828
2. [Iceberg Catalog Migrator](/iceberg-catalog-migrator/README.md): A command-line tool to migrate Iceberg tables from one Iceberg catalog to another.
2929
3. [Polaris Synchronizer](/polaris-synchronizer/README.md): A tool to migrate entities from one Polaris instance to another.
30+
4. [Polaris MCP Server](/mcp-server/README.md): A Polaris MCP server implementation.

mcp-server/.pre-commit-config.yaml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
repos:
19+
- repo: https://github.com/pre-commit/pre-commit-hooks
20+
rev: v5.0.0
21+
hooks:
22+
- id: trailing-whitespace
23+
files: ^mcp-server/
24+
- id: end-of-file-fixer
25+
files: ^mcp-server/
26+
- id: debug-statements
27+
files: ^mcp-server/
28+
- repo: https://github.com/astral-sh/ruff-pre-commit
29+
rev: v0.12.1
30+
hooks:
31+
# Run the linter.
32+
- id: ruff-check
33+
files: ^mcp-server/
34+
args: [ --fix, --exit-non-zero-on-fix ]
35+
# Run the formatter.
36+
- id: ruff-format
37+
files: ^mcp-server/

mcp-server/README.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<!--
2+
Licensed to the Apache Software Foundation (ASF) under one
3+
or more contributor license agreements. See the NOTICE file
4+
distributed with this work for additional information
5+
regarding copyright ownership. The ASF licenses this file
6+
to you under the Apache License, Version 2.0 (the
7+
"License"); you may not use this file except in compliance
8+
with the License. You may obtain a copy of the License at
9+
10+
http://www.apache.org/licenses/LICENSE-2.0
11+
12+
Unless required by applicable law or agreed to in writing,
13+
software distributed under the License is distributed on an
14+
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
KIND, either express or implied. See the License for the
16+
specific language governing permissions and limitations
17+
under the License.
18+
-->
19+
20+
# Apache Polaris MCP Server
21+
22+
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.
23+
24+
The implementation is built on top of [FastMCP](https://gofastmcp.com) for streamlined server registration and transport handling.
25+
26+
## Prerequisites
27+
- Python 3.10 or later
28+
- [uv](https://docs.astral.sh/uv/) 0.9.7 or later
29+
30+
## Building and Running
31+
Run the following commands from the `mcp-server` directory:
32+
- `uv sync` — install runtime dependencies
33+
- `uv run polaris-mcp` — start the MCP server (stdin/stdout transport)
34+
- `uv sync --extra test --extra dev` — install runtime, test and dev dependencies
35+
- `uv run pytest` — run the test suite
36+
- `uv run pre-commit run --all-files` — lint all files
37+
38+
For a `tools/call` invocation you will typically set environment variables such as `POLARIS_BASE_URL` and authentication settings before launching the server.
39+
40+
### Claude Desktop configuration
41+
42+
```json
43+
{
44+
"mcpServers": {
45+
"polaris": {
46+
"command": "uv",
47+
"args": [
48+
"--directory",
49+
"/path/to/polaris-tools/mcp-server",
50+
"run",
51+
"polaris-mcp"
52+
],
53+
"env": {
54+
"POLARIS_BASE_URL": "http://localhost:8181/",
55+
"POLARIS_CLIENT_ID": "root",
56+
"POLARIS_CLIENT_SECRET": "s3cr3t",
57+
"POLARIS_TOKEN_SCOPE": "PRINCIPAL_ROLE:ALL"
58+
}
59+
}
60+
}
61+
}
62+
```
63+
64+
Please note: `--directory` specifies a local directory. It is not needed when we pull `polaris-mcp` from PyPI package.
65+
66+
## Configuration
67+
68+
| Variable | Description | Default |
69+
|----------------------------------------------------------------|----------------------------------------------------------|--------------------------------------------------|
70+
| `POLARIS_BASE_URL` | Base URL for all Polaris REST calls. | `http://localhost:8181/` |
71+
| `POLARIS_API_TOKEN` / `POLARIS_BEARER_TOKEN` / `POLARIS_TOKEN` | Static bearer token (if supplied, overrides other auth). | _unset_ |
72+
| `POLARIS_CLIENT_ID` | OAuth client id for client-credential flow. | _unset_ |
73+
| `POLARIS_CLIENT_SECRET` | OAuth client secret. | _unset_ |
74+
| `POLARIS_TOKEN_SCOPE` | OAuth scope string. | _unset_ |
75+
| `POLARIS_TOKEN_URL` | Optional override for the token endpoint URL. | `${POLARIS_BASE_URL}api/catalog/v1/oauth/tokens` |
76+
77+
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.
78+
79+
## Tools
80+
81+
The server exposes the following MCP tools:
82+
83+
* `polaris-iceberg-table` — Table operations (`list`, `get`, `create`, `update`, `delete`).
84+
* `polaris-namespace-request` — Namespace lifecycle management.
85+
* `polaris-policy` — Policy lifecycle management and mappings.
86+
* `polaris-catalog-request` — Catalog lifecycle management.
87+
* `polaris-principal-request` — Principal lifecycle helpers.
88+
* `polaris-principal-role-request` — Principal role lifecycle and catalog-role assignments.
89+
* `polaris-catalog-role-request` — Catalog role and grant management.
90+
91+
Each tool returns both a human-readable transcript of the HTTP exchange and structured metadata under `result.meta`.

mcp-server/polaris_mcp/__init__.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one
3+
# or more contributor license agreements. See the NOTICE file
4+
# distributed with this work for additional information
5+
# regarding copyright ownership. The ASF licenses this file
6+
# to you under the Apache License, Version 2.0 (the
7+
# "License"); you may not use this file except in compliance
8+
# with the License. You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing,
13+
# software distributed under the License is distributed on an
14+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
# KIND, either express or implied. See the License for the
16+
# specific language governing permissions and limitations
17+
# under the License.
18+
#
19+
20+
"""Polaris Model Context Protocol server implementation."""
21+
22+
from .server import create_server, main
23+
24+
__all__ = ["create_server", "main"]
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
#
2+
# Licensed to the Apache Software Foundation (ASF) under one
3+
# or more contributor license agreements. See the NOTICE file
4+
# distributed with this work for additional information
5+
# regarding copyright ownership. The ASF licenses this file
6+
# to you under the Apache License, Version 2.0 (the
7+
# "License"); you may not use this file except in compliance
8+
# with the License. You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing,
13+
# software distributed under the License is distributed on an
14+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
# KIND, either express or implied. See the License for the
16+
# specific language governing permissions and limitations
17+
# under the License.
18+
#
19+
20+
"""Authorization helpers for the Polaris MCP server."""
21+
22+
from __future__ import annotations
23+
24+
import json
25+
import threading
26+
import time
27+
from abc import ABC, abstractmethod
28+
from typing import Optional
29+
from urllib.parse import urlencode
30+
31+
import urllib3
32+
33+
34+
class AuthorizationProvider(ABC):
35+
"""Return Authorization header values for outgoing requests."""
36+
37+
@abstractmethod
38+
def authorization_header(self) -> Optional[str]: ...
39+
40+
41+
class StaticAuthorizationProvider(AuthorizationProvider):
42+
"""Wrap a static bearer token."""
43+
44+
def __init__(self, token: Optional[str]) -> None:
45+
value = (token or "").strip()
46+
self._header = f"Bearer {value}" if value else None
47+
48+
def authorization_header(self) -> Optional[str]:
49+
return self._header
50+
51+
52+
class ClientCredentialsAuthorizationProvider(AuthorizationProvider):
53+
"""Implements the OAuth client-credentials flow with caching."""
54+
55+
def __init__(
56+
self,
57+
token_endpoint: str,
58+
client_id: str,
59+
client_secret: str,
60+
scope: Optional[str],
61+
http: urllib3.PoolManager,
62+
) -> None:
63+
self._token_endpoint = token_endpoint
64+
self._client_id = client_id
65+
self._client_secret = client_secret
66+
self._scope = scope
67+
self._http = http
68+
self._lock = threading.Lock()
69+
self._cached: Optional[tuple[str, float]] = None # (token, expires_at_epoch)
70+
71+
def authorization_header(self) -> Optional[str]:
72+
token = self._current_token()
73+
return f"Bearer {token}" if token else None
74+
75+
def _current_token(self) -> Optional[str]:
76+
now = time.time()
77+
cached = self._cached
78+
if not cached or cached[1] - 60 <= now:
79+
with self._lock:
80+
cached = self._cached
81+
if not cached or cached[1] - 60 <= time.time():
82+
self._cached = cached = self._fetch_token()
83+
return cached[0] if cached else None
84+
85+
def _fetch_token(self) -> tuple[str, float]:
86+
payload = {
87+
"grant_type": "client_credentials",
88+
"client_id": self._client_id,
89+
"client_secret": self._client_secret,
90+
}
91+
if self._scope:
92+
payload["scope"] = self._scope
93+
94+
encoded = urlencode(payload)
95+
response = self._http.request(
96+
"POST",
97+
self._token_endpoint,
98+
body=encoded,
99+
headers={"Content-Type": "application/x-www-form-urlencoded"},
100+
timeout=urllib3.Timeout(connect=20.0, read=20.0),
101+
)
102+
103+
if response.status != 200:
104+
raise RuntimeError(
105+
f"OAuth token endpoint returned {response.status}: {response.data.decode('utf-8', errors='ignore')}"
106+
)
107+
108+
try:
109+
document = json.loads(response.data.decode("utf-8"))
110+
except json.JSONDecodeError as error:
111+
raise RuntimeError("OAuth token endpoint returned invalid JSON") from error
112+
113+
token = document.get("access_token")
114+
if not isinstance(token, str) or not token:
115+
raise RuntimeError("OAuth token response missing access_token")
116+
117+
expires_in = document.get("expires_in", 3600)
118+
try:
119+
ttl = float(expires_in)
120+
except (TypeError, ValueError):
121+
ttl = 3600.0
122+
ttl = max(ttl, 60.0)
123+
expires_at = time.time() + ttl
124+
return token, expires_at
125+
126+
127+
class _NoneAuthorizationProvider(AuthorizationProvider):
128+
def authorization_header(self) -> Optional[str]:
129+
return None
130+
131+
132+
def none() -> AuthorizationProvider:
133+
"""Return an AuthorizationProvider that never supplies a header."""
134+
135+
return _NoneAuthorizationProvider()

0 commit comments

Comments
 (0)