This is an automated email from the ASF dual-hosted git repository.
mchades pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/branch-1.3 by this push:
new d853f1a0a5 [Cherry-pick to branch-1.3]
[#11565][#11566][#11568][#11572][#11575] feat(mcp-server): authentication,
per-request authorization and audit logging (#11622) (#11689)
d853f1a0a5 is described below
commit d853f1a0a58c7cc44d9f1e020ddb43315657e401
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Wed Jun 17 09:44:25 2026 +0800
[Cherry-pick to branch-1.3] [#11565][#11566][#11568][#11572][#11575]
feat(mcp-server): authentication, per-request authorization and audit logging
(#11622) (#11689)
**Cherry-pick Information:**
- Original commit: a98e74bc5fbccf4a6c56002b7668142d11f568e6
- Target branch: `branch-1.3`
- Status: ✅ Clean cherry-pick (no conflicts)
Co-authored-by: Qi Yu <[email protected]>
---
.github/workflows/mcp-integration-test.yml | 82 +++++++
mcp-server/.gitignore | 3 +
mcp-server/dev/INSPECTOR_DEMO.md | 208 +++++++++++++++++
mcp-server/dev/run_authz_integration_test.sh | 179 ++++++++++++++
mcp-server/dev/start_inspector_demo.sh | 260 +++++++++++++++++++++
mcp-server/dev/stop_inspector_demo.sh | 77 ++++++
mcp-server/mcp_server/client/factory.py | 7 +-
.../client/plain/plain_rest_client_operation.py | 23 +-
mcp-server/mcp_server/core/audit.py | 81 +++++++
mcp-server/mcp_server/core/context.py | 118 +++++++++-
mcp-server/mcp_server/core/setting.py | 21 +-
mcp-server/mcp_server/main.py | 46 +++-
mcp-server/mcp_server/server.py | 123 +++++++++-
mcp-server/pyproject.toml | 7 +
.../context.py => tests/integration/__init__.py} | 13 --
mcp-server/tests/integration/conftest.py | 73 ++++++
mcp-server/tests/integration/gravitino_setup.py | 145 ++++++++++++
mcp-server/tests/integration/test_authz_e2e.py | 175 ++++++++++++++
mcp-server/tests/unit/test_audit.py | 232 ++++++++++++++++++
mcp-server/tests/unit/test_auth_flow.py | 192 +++++++++++++++
mcp-server/tests/unit/test_per_request_token.py | 242 +++++++++++++++++++
mcp-server/tests/unit/test_transport_tls.py | 153 ++++++++++++
mcp-server/tests/unit/tools/mock_operation.py | 2 +-
23 files changed, 2427 insertions(+), 35 deletions(-)
diff --git a/.github/workflows/mcp-integration-test.yml
b/.github/workflows/mcp-integration-test.yml
new file mode 100644
index 0000000000..959d439e82
--- /dev/null
+++ b/.github/workflows/mcp-integration-test.yml
@@ -0,0 +1,82 @@
+#
+# 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.
+#
+name: mcp-integration-test
+
+on:
+ push:
+ branches: [ "main", "branch-*" ]
+ pull_request:
+ branches: [ "main", "branch-*" ]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number ||
github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ changes:
+ runs-on: ubuntu-latest
+ outputs:
+ mcp_or_authz_changes: ${{ steps.filter.outputs.mcp_or_authz_changes }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36
+ id: filter
+ with:
+ filters: |
+ mcp_or_authz_changes:
+ - 'mcp-server/**'
+ - 'server/**'
+ - 'core/src/main/java/org/apache/gravitino/authorization/**'
+ -
'server-common/src/main/java/org/apache/gravitino/server/authentication/**'
+ - '.github/workflows/mcp-integration-test.yml'
+
+ mcp-authz-integration-test:
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ needs: changes
+ if: needs.changes.outputs.mcp_or_authz_changes == 'true'
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: ./.github/actions/setup-java-toolchains
+ with:
+ java-version: 17
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v5
+
+ - name: Build Gravitino distribution
+ run: ./gradlew compileDistribution -x test -PskipWeb=true
+
+ - name: Run MCP authorization integration test
+ env:
+ GRAVITINO_HOME: ${{ github.workspace }}/distribution/package
+ run: |
+ ./mcp-server/dev/run_authz_integration_test.sh
+
+ - name: Upload server logs on failure
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: mcp-authz-it-logs
+ path: |
+ distribution/package/logs/**
+ mcp-server/gravitino-mcp-audit.log
+ mcp-server/gravitino-mcp.log
+ if-no-files-found: ignore
diff --git a/mcp-server/.gitignore b/mcp-server/.gitignore
index f2a99ec686..6ec30088be 100644
--- a/mcp-server/.gitignore
+++ b/mcp-server/.gitignore
@@ -34,6 +34,9 @@ logs/
*.err
gravitino_mcp_server.egg-info
+# Local run artifacts from dev/start_inspector_demo.sh (.out logs and .pid
files)
+.inspector-demo-*
+
# Unit test / coverage reports
htmlcov/
.tox/
diff --git a/mcp-server/dev/INSPECTOR_DEMO.md b/mcp-server/dev/INSPECTOR_DEMO.md
new file mode 100644
index 0000000000..d3a86f9b46
--- /dev/null
+++ b/mcp-server/dev/INSPECTOR_DEMO.md
@@ -0,0 +1,208 @@
+<!--
+ 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.
+-->
+
+# Hands-on MCP Authorization Demo (with MCP Inspector)
+
+Drive the Gravitino MCP server interactively through the
+[MCP Inspector](https://github.com/modelcontextprotocol/inspector) and watch
+per-user authorization and audit work end to end, as two principals
+(`admin` and `bob`).
+
+This exercises the three governance moments:
+
+1. **Scoped discovery** — `admin` and `bob` run the same call and get
different,
+ authorization-scoped results.
+2. **Write denied** — `bob` (read-only) attempts a write and is denied by
+ Gravitino authorization, surfaced as an explicit error through MCP.
+3. **Audit trail** — every call produces an audit record attributed to the
+ correct principal with an allow/deny outcome.
+
+---
+
+## 1. Prerequisites
+
+- A built Gravitino distribution. If you don't have one:
+ ```bash
+ ./gradlew compileDistribution -x test -PskipWeb=true
+ # produces distribution/package/
+ ```
+- `uv` available in the `mcp-server/` directory (the project already uses it).
+- Node.js (the start script launches the Inspector via `npx
@modelcontextprotocol/inspector`).
+- If you run behind an HTTP proxy, the scripts already export
+ `NO_PROXY=localhost,127.0.0.1`; make sure your shell doesn't force the proxy
+ for loopback in some other way.
+
+---
+
+## 2. Start the demo environment
+
+From the `mcp-server/` directory:
+
+```bash
+./dev/start_inspector_demo.sh
+```
+
+This script (idempotent — safe to re-run):
+
+1. Enables `simple` auth + authorization in
`distribution/package/conf/gravitino.conf`
+ (backing up the original).
+2. Starts Gravitino (or reuses a running one).
+3. Provisions demo data into metalake **`mcp_authz_it`**:
+ - `cat_allowed` and `cat_denied` (two model catalogs)
+ - user **`bob`**
+ - a reader role granting `bob` `USE_CATALOG` on `cat_allowed` **only**
+4. Starts the MCP server in HTTP mode at `http://127.0.0.1:8000/mcp`.
+5. Starts the **MCP Inspector** at `http://localhost:6274/` (with the
session-token
+ requirement disabled, so the plain URL works directly).
+
+On success it prints the connection details and the two principals' tokens. You
+should see the provisioning summary confirming the different slices:
+
+```
+[demo] admin sees catalogs: ['cat_allowed', 'cat_denied']
+[demo] bob sees catalogs: ['cat_allowed']
+```
+
+> If a distribution isn't found, set `GRAVITINO_HOME=/path/to/distribution`.
+
+---
+
+## 3. Open the Inspector
+
+The start script already launched it. Just open:
+
+```
+http://localhost:6274/
+```
+
+(No session token needed — the script starts it with
`DANGEROUSLY_OMIT_AUTH=true`
+for local convenience.)
+
+### Connect
+
+| Field | Value |
+|----------------|------------------------------------------|
+| Transport Type | `Streamable HTTP` |
+| URL | `http://127.0.0.1:8000/mcp` |
+| Header Name | `Authorization` |
+| Header Value | `Basic YWRtaW46ZHVtbXk=` (this is `admin`) |
+
+The two principal tokens (these are Gravitino simple-auth headers, i.e.
+`Basic base64("<user>:dummy")`):
+
+| Principal | Authorization header value |
+|-----------|--------------------------------|
+| `admin` | `Basic YWRtaW46ZHVtbXk=` |
+| `bob` | `Basic Ym9iOmR1bW15` |
+
+Click **Connect**, then **List Tools** — you should see the full read + write
+tool surface (catalogs, schemas, tables, filesets, topics, models, tags, …).
+
+> **Identity is the header.** The Inspector sends your `Authorization` header
on
+> every request; the MCP server forwards it verbatim to Gravitino, which
+> authorizes against that principal. To switch principals, change the header
+> value and reconnect.
+
+---
+
+## 4. The three scenarios
+
+Keep a terminal tailing the audit log while you click:
+
+```bash
+tail -f gravitino-mcp-audit.log
+```
+
+### Scenario 1 — Scoped discovery
+
+1. Connected as **admin**, run tool **`get_list_of_catalogs`**.
+ → Returns **both** `cat_allowed` and `cat_denied`.
+2. Reconnect with the **bob** header (`Basic Ym9iOmR1bW15`), run
+ **`get_list_of_catalogs`** again.
+ → Returns **only** `cat_allowed`.
+
+Same call, different results — sourced entirely from Gravitino's list
filtering,
+not from any logic in the MCP server.
+
+### Scenario 2 — Write denied by authorization
+
+Still connected as **bob**, run **`create_tag`** with arguments:
+
+```json
+{ "name": "test_tag", "comment": "x", "properties": {} }
+```
+
+→ You get an explicit error, e.g.
+`User 'bob' is not authorized to perform operation 'createTag' on metadata
'mcp_authz_it'`.
+
+It's a real authorization denial, not a hidden tool or a silent no-op.
Reconnect
+as **admin** and run the same `create_tag` — it succeeds (admin owns the
metalake).
+
+### Scenario 3 — Audit trail
+
+Look at the audit log you've been tailing. You should see discrete, correctly
+attributed records, for example:
+
+```json
+{"timestamp": "...", "principal": "admin", "tool": "get_list_of_catalogs",
"outcome": "allow"}
+{"timestamp": "...", "principal": "bob", "tool": "get_list_of_catalogs",
"outcome": "allow"}
+{"timestamp": "...", "principal": "bob", "tool": "create_tag", "outcome":
"deny", "error_type": "McpError"}
+```
+
+`admin`'s reads attributed to `admin`, `bob`'s reads to `bob`, and `bob`'s
denied
+write to `bob` with a `deny` outcome.
+
+---
+
+## 5. Tear down
+
+```bash
+./dev/stop_inspector_demo.sh
+```
+
+Stops the Inspector, the MCP server, and Gravitino, and restores the original
+`gravitino.conf`.
+
+---
+
+## 6. Troubleshooting
+
+**The audit log looks empty / stuck at 0 bytes.**
+The MCP server opens the audit file once at startup and holds the handle. If
you
+`rm` the file while the server is running, the process keeps writing to the now
+unlinked inode and a new empty file appears at the path — so you see nothing.
+Don't delete it mid-run; truncate instead (`: > gravitino-mcp-audit.log`), or
+restart the server. The start script truncates rather than deletes for exactly
+this reason.
+
+**`HTTP 000` / `502` when curling localhost.**
+An HTTP proxy is intercepting loopback traffic. Export
+`NO_PROXY=localhost,127.0.0.1` (the scripts already do) or pass `curl
--noproxy '*'`.
+
+**MCP server fails to bind (`can't assign requested address`).**
+`localhost` resolved to a non-loopback address. The scripts bind to the
+`127.0.0.1` literal to avoid this.
+
+**`ModuleNotFoundError: No module named 'mcp_server'`.**
+Launch with `uv run python -m mcp_server ...` (the scripts do); `uv run
mcp_server`
+needs an editable install.
+
+**Re-running the start script.**
+It's idempotent: it reuses a running Gravitino/MCP server and drops+recreates
the
+demo metalake, so you can re-run it freely.
diff --git a/mcp-server/dev/run_authz_integration_test.sh
b/mcp-server/dev/run_authz_integration_test.sh
new file mode 100755
index 0000000000..82673806b7
--- /dev/null
+++ b/mcp-server/dev/run_authz_integration_test.sh
@@ -0,0 +1,179 @@
+#!/bin/bash
+#
+# 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.
+#
+# End-to-end authorization integration test for the Gravitino MCP server.
+#
+# This script:
+# 1. Builds the Gravitino distribution (unless GRAVITINO_HOME is provided).
+# 2. Enables simple authentication + authorization (serviceAdmins=admin).
+# 3. Starts the Gravitino server.
+# 4. Starts the MCP server in HTTP transport mode.
+# 5. Runs the pytest integration suite (which provisions metadata as admin
and
+# verifies per-user authorization through MCP).
+# 6. Tears everything down and restores the original config.
+#
+# Usage:
+# ./dev/run_authz_integration_test.sh
+# GRAVITINO_HOME=/path/to/distribution ./dev/run_authz_integration_test.sh
+
+set -euo pipefail
+
+# All services run on localhost; never route them through an HTTP proxy.
+# httpx (MCP server -> Gravitino, test client) and curl both honour NO_PROXY.
+export NO_PROXY="localhost,127.0.0.1"
+export no_proxy="localhost,127.0.0.1"
+
+MCP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+REPO_ROOT="$(cd "${MCP_DIR}/.." && pwd)"
+
+# Use the loopback literal (not "localhost") so the MCP server binds to a
+# guaranteed-assignable address; on some hosts "localhost" resolves to a LAN
IP.
+GRAVITINO_PORT="${GRAVITINO_PORT:-8090}"
+GRAVITINO_URI="http://127.0.0.1:${GRAVITINO_PORT}"
+MCP_PORT="${MCP_PORT:-8000}"
+MCP_URL="http://127.0.0.1:${MCP_PORT}/mcp"
+MCP_METALAKE="${MCP_METALAKE:-mcp_authz_it}"
+MCP_AUDIT_LOG="${MCP_DIR}/gravitino-mcp-audit.log"
+
+MCP_PID=""
+GRAVITINO_STARTED="false"
+CONF_BACKUP=""
+
+log() { echo "[authz-it] $*"; }
+
+cleanup() {
+ log "Tearing down..."
+ if [[ -n "${MCP_PID}" ]] && kill -0 "${MCP_PID}" 2>/dev/null; then
+ kill "${MCP_PID}" 2>/dev/null || true
+ wait "${MCP_PID}" 2>/dev/null || true
+ fi
+ if [[ "${GRAVITINO_STARTED}" == "true" ]]; then
+ "${GRAVITINO_HOME}/bin/gravitino.sh" stop || true
+ fi
+ if [[ -n "${CONF_BACKUP}" && -f "${CONF_BACKUP}" ]]; then
+ mv "${CONF_BACKUP}" "${GRAVITINO_HOME}/conf/gravitino.conf"
+ log "Restored original gravitino.conf"
+ fi
+}
+trap cleanup EXIT
+
+# ---------------------------------------------------------------------------
+# 1. Resolve / build the Gravitino distribution
+# ---------------------------------------------------------------------------
+if [[ -z "${GRAVITINO_HOME:-}" ]]; then
+ log "GRAVITINO_HOME not set; building distribution..."
+ (cd "${REPO_ROOT}" && ./gradlew compileDistribution -x test)
+ GRAVITINO_HOME="${REPO_ROOT}/distribution/package"
+fi
+log "Using GRAVITINO_HOME=${GRAVITINO_HOME}"
+
+if [[ ! -x "${GRAVITINO_HOME}/bin/gravitino.sh" ]]; then
+ log "ERROR: ${GRAVITINO_HOME}/bin/gravitino.sh not found"
+ exit 1
+fi
+
+# ---------------------------------------------------------------------------
+# 2. Enable simple auth + authorization
+# ---------------------------------------------------------------------------
+CONF="${GRAVITINO_HOME}/conf/gravitino.conf"
+CONF_BACKUP="${CONF}.authz-it.bak"
+cp "${CONF}" "${CONF_BACKUP}"
+
+# Remove any pre-existing values for the keys we manage, then append ours.
+sed -i.tmp \
+ -e '/^gravitino.authenticators/d' \
+ -e '/^gravitino.authorization.enable/d' \
+ -e '/^gravitino.authorization.serviceAdmins/d' \
+ "${CONF}"
+rm -f "${CONF}.tmp"
+cat >> "${CONF}" <<EOF
+
+# --- injected by run_authz_integration_test.sh ---
+gravitino.authenticators = simple
+gravitino.authorization.enable = true
+gravitino.authorization.serviceAdmins = admin
+EOF
+log "Configured simple auth + authorization (serviceAdmins=admin)"
+
+# ---------------------------------------------------------------------------
+# 3. Start Gravitino
+# ---------------------------------------------------------------------------
+log "Starting Gravitino server..."
+"${GRAVITINO_HOME}/bin/gravitino.sh" start
+GRAVITINO_STARTED="true"
+
+# With simple auth enabled every request needs an Authorization header.
+ADMIN_AUTH="Basic $(printf '%s' 'admin:dummy' | base64)"
+
+log "Waiting for Gravitino to become healthy..."
+for i in $(seq 1 60); do
+ if curl -sf --noproxy '*' -H "Authorization: ${ADMIN_AUTH}" \
+ "${GRAVITINO_URI}/api/version" >/dev/null 2>&1; then
+ log "Gravitino is up."
+ break
+ fi
+ if [[ "${i}" == "60" ]]; then
+ log "ERROR: Gravitino did not become healthy in time"
+ exit 1
+ fi
+ sleep 2
+done
+
+# ---------------------------------------------------------------------------
+# 4. Start the MCP server in HTTP transport mode
+# ---------------------------------------------------------------------------
+log "Starting MCP server (HTTP) on ${MCP_URL}..."
+rm -f "${MCP_AUDIT_LOG}"
+(
+ cd "${MCP_DIR}"
+ uv run python -m mcp_server \
+ --metalake "${MCP_METALAKE}" \
+ --gravitino-uri "${GRAVITINO_URI}" \
+ --transport http \
+ --mcp-url "${MCP_URL}"
+) &
+MCP_PID=$!
+
+log "Waiting for MCP server to become reachable..."
+for i in $(seq 1 30); do
+ if nc -z localhost "${MCP_PORT}" 2>/dev/null; then
+ log "MCP server is up."
+ break
+ fi
+ if [[ "${i}" == "30" ]]; then
+ log "ERROR: MCP server did not start in time"
+ exit 1
+ fi
+ sleep 1
+done
+
+# ---------------------------------------------------------------------------
+# 5. Run the pytest integration suite
+# ---------------------------------------------------------------------------
+log "Running integration tests..."
+(
+ cd "${MCP_DIR}"
+ GRAVITINO_URI="${GRAVITINO_URI}" \
+ MCP_URL="${MCP_URL}" \
+ MCP_METALAKE="${MCP_METALAKE}" \
+ MCP_AUDIT_LOG="${MCP_AUDIT_LOG}" \
+ uv run pytest tests/integration -v
+)
+
+log "Integration tests passed."
diff --git a/mcp-server/dev/start_inspector_demo.sh
b/mcp-server/dev/start_inspector_demo.sh
new file mode 100755
index 0000000000..65ac519f72
--- /dev/null
+++ b/mcp-server/dev/start_inspector_demo.sh
@@ -0,0 +1,260 @@
+#!/bin/bash
+#
+# 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.
+#
+# Bring up a persistent local demo environment for hands-on MCP authorization
+# testing with the MCP Inspector:
+#
+# 1. Gravitino server with simple auth + authorization enabled.
+# 2. Demo data: metalake + two catalogs, user "bob", a role granting bob
+# access to only one catalog (so admin and bob see different slices).
+# 3. MCP server in HTTP transport mode.
+#
+# Unlike run_authz_integration_test.sh, this script LEAVES everything running
so
+# you can drive it from the Inspector. Run stop_inspector_demo.sh to tear down.
+#
+# Usage:
+# ./dev/start_inspector_demo.sh
+# GRAVITINO_HOME=/path/to/distribution ./dev/start_inspector_demo.sh
+
+set -euo pipefail
+
+# Everything is local; never route through an HTTP proxy.
+export NO_PROXY="localhost,127.0.0.1"
+export no_proxy="localhost,127.0.0.1"
+
+MCP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+REPO_ROOT="$(cd "${MCP_DIR}/.." && pwd)"
+
+GRAVITINO_PORT="${GRAVITINO_PORT:-8090}"
+GRAVITINO_URI="http://127.0.0.1:${GRAVITINO_PORT}"
+MCP_PORT="${MCP_PORT:-8000}"
+MCP_URL="http://127.0.0.1:${MCP_PORT}/mcp"
+MCP_METALAKE="${MCP_METALAKE:-mcp_authz_it}"
+MCP_PID_FILE="${MCP_DIR}/.inspector-demo-mcp.pid"
+ADMIN_AUTH="Basic $(printf '%s' 'admin:dummy' | base64)"
+
+# MCP Inspector ports (defaults baked into @modelcontextprotocol/inspector).
+INSPECTOR_UI_PORT="${INSPECTOR_UI_PORT:-6274}"
+INSPECTOR_PROXY_PORT="${INSPECTOR_PROXY_PORT:-6277}"
+INSPECTOR_PID_FILE="${MCP_DIR}/.inspector-demo-inspector.pid"
+
+log() { echo "[demo] $*"; }
+
+# ---------------------------------------------------------------------------
+# 1. Resolve the Gravitino distribution
+# ---------------------------------------------------------------------------
+if [[ -z "${GRAVITINO_HOME:-}" ]]; then
+ GRAVITINO_HOME="${REPO_ROOT}/distribution/package"
+fi
+if [[ ! -x "${GRAVITINO_HOME}/bin/gravitino.sh" ]]; then
+ log "Distribution not found at ${GRAVITINO_HOME}."
+ log "Build it first: ./gradlew compileDistribution -x test -PskipWeb=true"
+ log "Or set GRAVITINO_HOME to an existing distribution."
+ exit 1
+fi
+log "Using GRAVITINO_HOME=${GRAVITINO_HOME}"
+
+# ---------------------------------------------------------------------------
+# 2. Enable simple auth + authorization (idempotent)
+# ---------------------------------------------------------------------------
+CONF="${GRAVITINO_HOME}/conf/gravitino.conf"
+if ! grep -q "gravitino.authorization.enable = true" "${CONF}"; then
+ cp "${CONF}" "${CONF}.inspector-demo.bak"
+ sed -i.tmp \
+ -e '/^gravitino.authenticators/d' \
+ -e '/^gravitino.authorization.enable/d' \
+ -e '/^gravitino.authorization.serviceAdmins/d' \
+ "${CONF}"
+ rm -f "${CONF}.tmp"
+ cat >> "${CONF}" <<EOF
+
+# --- injected by start_inspector_demo.sh (restored on stop) ---
+gravitino.authenticators = simple
+gravitino.authorization.enable = true
+gravitino.authorization.serviceAdmins = admin
+EOF
+ log "Configured simple auth + authorization (serviceAdmins=admin)"
+else
+ log "Authorization already enabled in config"
+fi
+
+# ---------------------------------------------------------------------------
+# 3. Start Gravitino (if not already up)
+# ---------------------------------------------------------------------------
+if curl -sf --noproxy '*' -H "Authorization: ${ADMIN_AUTH}" \
+ "${GRAVITINO_URI}/api/version" >/dev/null 2>&1; then
+ log "Gravitino already running on ${GRAVITINO_PORT}"
+else
+ log "Starting Gravitino server..."
+ "${GRAVITINO_HOME}/bin/gravitino.sh" start
+ log "Waiting for Gravitino to become healthy..."
+ for i in $(seq 1 60); do
+ if curl -sf --noproxy '*' -H "Authorization: ${ADMIN_AUTH}" \
+ "${GRAVITINO_URI}/api/version" >/dev/null 2>&1; then
+ log "Gravitino is up."
+ break
+ fi
+ if [[ "${i}" == "60" ]]; then
+ log "ERROR: Gravitino did not become healthy in time"
+ exit 1
+ fi
+ sleep 2
+ done
+fi
+
+# ---------------------------------------------------------------------------
+# 4. Provision demo data (idempotent: drop + recreate the metalake)
+# ---------------------------------------------------------------------------
+log "Provisioning demo data into metalake '${MCP_METALAKE}'..."
+(
+ cd "${MCP_DIR}"
+ GRAVITINO_URI="${GRAVITINO_URI}" MCP_METALAKE="${MCP_METALAKE}" \
+ uv run python <<'PY'
+import base64
+import os
+
+import httpx
+
+from tests.integration.gravitino_setup import GravitinoFixture
+
+uri = os.environ["GRAVITINO_URI"]
+ml = os.environ["MCP_METALAKE"]
+
+
+def hdr(user):
+ return "Basic " + base64.b64encode(f"{user}:dummy".encode()).decode()
+
+
+# Drop an existing metalake so the script can be re-run cleanly.
+client = httpx.Client(
+ base_url=uri, headers={"Authorization": hdr("admin")}, timeout=30
+)
+if client.get(f"/api/metalakes/{ml}").status_code == 200:
+ client.put(
+ f"/api/metalakes/{ml}",
+ json={"updates": [{"@type": "setProperty", "property": "in-use",
"value": "false"}]},
+ )
+ client.delete(f"/api/metalakes/{ml}?force=true")
+ print(f"[demo] removed existing metalake '{ml}'")
+client.close()
+
+GravitinoFixture(uri, ml).provision()
+print(f"[demo] provisioned metalake '{ml}': cat_allowed, cat_denied, user bob,
reader role")
+
+# Show the resulting authorization slice.
+for user in ("admin", "bob"):
+ r = httpx.get(
+ f"{uri}/api/metalakes/{ml}/catalogs?details=true",
+ headers={"Authorization": hdr(user)},
+ )
+ names = [c["name"] for c in r.json().get("catalogs", [])]
+ print(f"[demo] {user} sees catalogs: {names}")
+PY
+)
+
+# ---------------------------------------------------------------------------
+# 5. Start the MCP server in HTTP mode (if not already up)
+# ---------------------------------------------------------------------------
+if nc -z 127.0.0.1 "${MCP_PORT}" 2>/dev/null; then
+ log "An MCP server is already listening on ${MCP_PORT}; leaving it as-is."
+else
+ log "Starting MCP server (HTTP) on ${MCP_URL}..."
+ (
+ cd "${MCP_DIR}"
+ # Truncate (not delete) the audit log so the running process keeps its
handle.
+ : > gravitino-mcp-audit.log
+ nohup uv run python -m mcp_server \
+ --metalake "${MCP_METALAKE}" \
+ --gravitino-uri "${GRAVITINO_URI}" \
+ --transport http \
+ --mcp-url "${MCP_URL}" > "${MCP_DIR}/.inspector-demo-mcp.out" 2>&1 &
+ echo $! > "${MCP_PID_FILE}"
+ )
+ for i in $(seq 1 30); do
+ nc -z 127.0.0.1 "${MCP_PORT}" 2>/dev/null && break
+ sleep 1
+ done
+ if nc -z 127.0.0.1 "${MCP_PORT}" 2>/dev/null; then
+ log "MCP server is up (pid $(cat "${MCP_PID_FILE}"))."
+ else
+ log "ERROR: MCP server did not start; see
${MCP_DIR}/.inspector-demo-mcp.out"
+ exit 1
+ fi
+fi
+
+# ---------------------------------------------------------------------------
+# 6. Start the MCP Inspector (UI on :6274, proxy on :6277)
+# ---------------------------------------------------------------------------
+# DANGEROUSLY_OMIT_AUTH disables the session-token requirement so the plain
+# http://localhost:6274/ URL works without a token (fine for a local demo).
+# MCP_AUTO_OPEN_ENABLED=false keeps it from popping a browser when
backgrounded.
+# The Inspector binds to "localhost" (may be IPv6 ::1), so probe via localhost.
+if nc -z localhost "${INSPECTOR_UI_PORT}" 2>/dev/null; then
+ log "An Inspector is already listening on ${INSPECTOR_UI_PORT}; leaving it
as-is."
+else
+ log "Starting MCP Inspector on http://localhost:${INSPECTOR_UI_PORT} ..."
+ (
+ cd "${MCP_DIR}"
+ DANGEROUSLY_OMIT_AUTH=true \
+ MCP_AUTO_OPEN_ENABLED=false \
+ nohup npx @modelcontextprotocol/inspector \
+ > "${MCP_DIR}/.inspector-demo-inspector.out" 2>&1 &
+ echo $! > "${INSPECTOR_PID_FILE}"
+ )
+ for i in $(seq 1 60); do
+ nc -z localhost "${INSPECTOR_UI_PORT}" 2>/dev/null && break
+ sleep 1
+ done
+ if nc -z localhost "${INSPECTOR_UI_PORT}" 2>/dev/null; then
+ log "Inspector is up (pid $(cat "${INSPECTOR_PID_FILE}"))."
+ else
+ log "WARN: Inspector did not come up; see
${MCP_DIR}/.inspector-demo-inspector.out"
+ log " (You can still start it manually: npx
@modelcontextprotocol/inspector)"
+ fi
+fi
+
+# ---------------------------------------------------------------------------
+# 7. Print connection details
+# ---------------------------------------------------------------------------
+cat <<EOF
+
+============================================================
+ Demo environment is ready.
+============================================================
+ Gravitino : ${GRAVITINO_URI} (simple auth + authorization)
+ MCP server: ${MCP_URL} (Streamable HTTP)
+ Metalake : ${MCP_METALAKE}
+ Audit log : ${MCP_DIR}/gravitino-mcp-audit.log
+
+ >>> Open the Inspector: http://localhost:${INSPECTOR_UI_PORT}/
+
+ In the Inspector, connect with:
+ Transport Type : Streamable HTTP
+ URL : ${MCP_URL}
+ Header Name : Authorization
+ Header Value : ${ADMIN_AUTH} <- admin
+ Basic $(printf '%s' 'bob:dummy' | base64) <- bob
+
+ Watch audit records live:
+ tail -f ${MCP_DIR}/gravitino-mcp-audit.log
+
+ Tear everything down (includes the Inspector):
+ ./dev/stop_inspector_demo.sh
+============================================================
+EOF
diff --git a/mcp-server/dev/stop_inspector_demo.sh
b/mcp-server/dev/stop_inspector_demo.sh
new file mode 100755
index 0000000000..0335dac0f2
--- /dev/null
+++ b/mcp-server/dev/stop_inspector_demo.sh
@@ -0,0 +1,77 @@
+#!/bin/bash
+#
+# 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.
+#
+# Tear down the demo environment started by start_inspector_demo.sh:
+# stops the MCP server, stops Gravitino, and restores the original config.
+
+set -uo pipefail
+
+MCP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+REPO_ROOT="$(cd "${MCP_DIR}/.." && pwd)"
+
+MCP_PORT="${MCP_PORT:-8000}"
+MCP_PID_FILE="${MCP_DIR}/.inspector-demo-mcp.pid"
+INSPECTOR_UI_PORT="${INSPECTOR_UI_PORT:-6274}"
+INSPECTOR_PROXY_PORT="${INSPECTOR_PROXY_PORT:-6277}"
+INSPECTOR_PID_FILE="${MCP_DIR}/.inspector-demo-inspector.pid"
+
+log() { echo "[demo] $*"; }
+
+if [[ -z "${GRAVITINO_HOME:-}" ]]; then
+ GRAVITINO_HOME="${REPO_ROOT}/distribution/package"
+fi
+
+# 1. Stop the MCP Inspector (npx spawns child processes; free its ports too).
+if [[ -f "${INSPECTOR_PID_FILE}" ]]; then
+ INSPECTOR_PID="$(cat "${INSPECTOR_PID_FILE}")"
+ kill "${INSPECTOR_PID}" 2>/dev/null && \
+ log "Stopped Inspector (pid ${INSPECTOR_PID})" || true
+ rm -f "${INSPECTOR_PID_FILE}"
+fi
+for port in "${INSPECTOR_UI_PORT}" "${INSPECTOR_PROXY_PORT}"; do
+ lsof -ti :"${port}" 2>/dev/null | xargs kill -9 2>/dev/null && \
+ log "Freed Inspector port ${port}" || true
+done
+
+# 2. Stop the MCP server.
+if [[ -f "${MCP_PID_FILE}" ]]; then
+ MCP_PID="$(cat "${MCP_PID_FILE}")"
+ if kill "${MCP_PID}" 2>/dev/null; then
+ log "Stopped MCP server (pid ${MCP_PID})"
+ fi
+ rm -f "${MCP_PID_FILE}"
+fi
+# Belt and suspenders: free the port if anything is still bound.
+lsof -ti :"${MCP_PORT}" 2>/dev/null | xargs kill -9 2>/dev/null && \
+ log "Freed port ${MCP_PORT}" || true
+
+# 3. Stop Gravitino.
+if [[ -x "${GRAVITINO_HOME}/bin/gravitino.sh" ]]; then
+ "${GRAVITINO_HOME}/bin/gravitino.sh" stop || true
+ log "Stopped Gravitino"
+fi
+
+# 4. Restore the original config.
+CONF="${GRAVITINO_HOME}/conf/gravitino.conf"
+if [[ -f "${CONF}.inspector-demo.bak" ]]; then
+ mv "${CONF}.inspector-demo.bak" "${CONF}"
+ log "Restored original gravitino.conf"
+fi
+
+log "Teardown complete."
diff --git a/mcp-server/mcp_server/client/factory.py
b/mcp-server/mcp_server/client/factory.py
index d990284d28..201625540d 100644
--- a/mcp-server/mcp_server/client/factory.py
+++ b/mcp-server/mcp_server/client/factory.py
@@ -29,7 +29,7 @@ class RESTClientFactory:
@classmethod
def create_rest_client(
- cls, metalake_name: str, uri: str
+ cls, metalake_name: str, uri: str, authorization: str = ""
) -> "PlainRESTClientOperation":
"""
Create a new rest client instance with the specified parameters.
@@ -37,11 +37,14 @@ class RESTClientFactory:
Args:
metalake_name: Name of the metalake
uri: URI of the Gravitino server endpoint
+ authorization: Full Authorization header value forwarded verbatim
+ (e.g. "Bearer <token>" or "Basic <base64(user:dummy)>").
+ Empty string means anonymous.
Returns:
New instance of the configured rest client class
"""
- return cls._rest_client_class(metalake_name, uri)
+ return cls._rest_client_class(metalake_name, uri, authorization)
@classmethod
def set_rest_client(cls, rest_client_class: type) -> None:
diff --git a/mcp-server/mcp_server/client/plain/plain_rest_client_operation.py
b/mcp-server/mcp_server/client/plain/plain_rest_client_operation.py
index 205562b6d8..a6adc0e099 100644
--- a/mcp-server/mcp_server/client/plain/plain_rest_client_operation.py
+++ b/mcp-server/mcp_server/client/plain/plain_rest_client_operation.py
@@ -62,8 +62,23 @@ from mcp_server.client.topic_operation import TopicOperation
# pylint: disable=too-many-instance-attributes
class PlainRESTClientOperation(GravitinoOperation):
- def __init__(self, metalake_name: str, uri: str):
- _rest_client = httpx.AsyncClient(base_url=uri)
+ def __init__(self, metalake_name: str, uri: str, authorization: str = ""):
+ """Create a REST client for one identity.
+
+ Args:
+ metalake_name: Name of the metalake.
+ uri: Gravitino server URI.
+ authorization: Full ``Authorization`` header value forwarded
verbatim
+ on every request (for example ``"Bearer <token>"`` for OAuth2
or
+ ``"Basic <base64(user:secret)>"`` for simple or Basic auth).
+ Empty string means anonymous (no header sent).
+ """
+ headers = {}
+ if authorization:
+ headers["Authorization"] = authorization
+ _rest_client = httpx.AsyncClient(base_url=uri, headers=headers)
+ # Kept so the shared connection pool can be closed (see close()).
+ self._rest_client = _rest_client
self._catalog_operation = PlainRESTClientCatalogOperation(
metalake_name, _rest_client
)
@@ -95,6 +110,10 @@ class PlainRESTClientOperation(GravitinoOperation):
metalake_name, _rest_client
)
+ async def close(self) -> None:
+ """Close the shared httpx client and release its connection pool."""
+ await self._rest_client.aclose()
+
def as_catalog_operation(self) -> CatalogOperation:
return self._catalog_operation
diff --git a/mcp-server/mcp_server/core/audit.py
b/mcp-server/mcp_server/core/audit.py
new file mode 100644
index 0000000000..2de8a137dc
--- /dev/null
+++ b/mcp-server/mcp_server/core/audit.py
@@ -0,0 +1,81 @@
+# 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.
+
+import base64
+import binascii
+import json
+import logging
+from datetime import datetime, timezone
+
+_audit_logger = logging.getLogger("gravitino.mcp.audit")
+
+
+def _extract_principal(authorization: str) -> str:
+ """Derive a display principal from a raw Authorization header value.
+
+ - "Basic <base64(user:secret)>" → "<user>" (Gravitino simple auth)
+ - "Bearer <token>" → "bearer:<first-8-chars-of-token>"
+ - empty / missing / unparsable → "anonymous"
+ """
+ if not authorization:
+ return "anonymous"
+ parts = authorization.split()
+ if len(parts) != 2:
+ return "anonymous"
+ scheme, credential = parts[0].lower(), parts[1]
+ if scheme == "basic":
+ try:
+ decoded = base64.b64decode(credential, validate=True).decode(
+ "utf-8"
+ )
+ except (binascii.Error, UnicodeDecodeError, ValueError):
+ return "anonymous"
+ user = decoded.split(":", 1)[0]
+ return user if user else "anonymous"
+ if scheme == "bearer":
+ return f"bearer:{credential[:8]}"
+ return "anonymous"
+
+
+def emit(
+ *,
+ principal: str,
+ tool: str,
+ outcome: str,
+ error_type: str = "",
+) -> None:
+ """Write one structured JSON audit record to the audit logger.
+
+ Args:
+ principal: Identity derived from the request (e.g. "bearer:abc12345"
or "anonymous").
+ tool: MCP tool name that was invoked.
+ outcome: "allow" for successful calls, "deny" for failed calls. Note
the
+ AuditMiddleware emits "deny" for any tool-call exception (an
+ authorization denial being the common case), not only
+ authorization failures; inspect error_type to disambiguate.
+ error_type: Exception class name when outcome is "deny", empty
otherwise.
+ """
+ record = {
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ "principal": principal,
+ "tool": tool,
+ "outcome": outcome,
+ }
+ if error_type:
+ record["error_type"] = error_type
+
+ _audit_logger.info(json.dumps(record))
diff --git a/mcp-server/mcp_server/core/context.py
b/mcp-server/mcp_server/core/context.py
index 6e540a0b51..fd6731b495 100644
--- a/mcp-server/mcp_server/core/context.py
+++ b/mcp-server/mcp_server/core/context.py
@@ -15,15 +15,127 @@
# specific language governing permissions and limitations
# under the License.
+import asyncio
+import logging
+from collections import OrderedDict
+
from mcp_server.client.factory import RESTClientFactory
from mcp_server.core.setting import Setting
+_LOG = logging.getLogger(__name__)
+
+# Upper bound on the number of per-principal REST clients kept alive at once.
+# Each client owns an httpx connection pool; caching by Authorization header
lets
+# repeated calls from the same principal reuse a pool instead of opening a new
one
+# per tool call, while the LRU bound keeps memory/sockets in check as
principals
+# (e.g. rotating tokens) come and go.
+_MAX_CACHED_CLIENTS = 128
+
+
+def _get_request_authorization() -> str:
+ """Return the raw ``Authorization`` header of the current HTTP request.
+
+ The header is forwarded to Gravitino verbatim so the auth scheme chosen by
+ the agent (``Basic`` for simple or Basic auth, ``Bearer`` for OAuth2,
+ ``Negotiate`` for Kerberos) is preserved. Returns an empty string in stdio
+ mode or when the header is absent.
+ """
+ try:
+ # Imported lazily: only available within an HTTP request context.
+ # pylint: disable=import-outside-toplevel
+ from fastmcp.server.dependencies import get_http_request
+
+ return get_http_request().headers.get("authorization", "")
+ except (LookupError, RuntimeError):
+ # No active HTTP request: stdio mode (get_http_request raises
+ # RuntimeError) or missing request context (LookupError).
+ return ""
+
+
+def startup_authorization(setting: Setting) -> str:
+ """The static --token rendered as an ``Authorization`` header value.
+
+ The CLI token is treated as an OAuth2 Bearer token. Empty string when no
+ token is configured (anonymous). This is the identity used in stdio mode
and
+ the fallback for HTTP requests that carry no ``Authorization`` header.
+ """
+ return f"Bearer {setting.token}" if setting.token else ""
+
class GravitinoContext:
def __init__(self, setting: Setting):
- self.gravitino_client = RESTClientFactory.create_rest_client(
- setting.metalake, setting.gravitino_uri
+ self._setting = setting
+ self._default_client = RESTClientFactory.create_rest_client(
+ setting.metalake,
+ setting.gravitino_uri,
+ startup_authorization(setting),
)
+ # LRU cache of per-principal clients keyed by the raw Authorization
header.
+ # Safe without locking: rest_client() runs on the single asyncio event
+ # loop and never awaits between lookup and insert.
+ self._clients_by_auth: "OrderedDict[str, object]" = OrderedDict()
+ # Strong references to in-flight background close tasks; the event loop
+ # only keeps weak references, so without this they could be GC'd before
+ # running. Entries are discarded when each task completes.
+ self._pending_closes: "set[asyncio.Task]" = set()
def rest_client(self):
- return self.gravitino_client
+ """Return a REST client carrying the correct identity for this request.
+
+ In HTTP transport mode the incoming request's ``Authorization`` header
is
+ forwarded verbatim to Gravitino, taking priority over the static
startup
+ token. This keeps concurrent sessions with different principals fully
+ isolated — one principal's identity never leaks into another's calls.
+
+ Falls back to the shared default client (static startup token) when:
+ - running in stdio mode (no HTTP request context), or
+ - the incoming request carries no Authorization header.
+
+ Per-principal clients are cached (and their connection pools reused)
so a
+ new pool is not opened on every tool call.
+ """
+ authorization = _get_request_authorization()
+ if not authorization:
+ return self._default_client
+
+ cached = self._clients_by_auth.get(authorization)
+ if cached is not None:
+ self._clients_by_auth.move_to_end(authorization)
+ return cached
+
+ client = RESTClientFactory.create_rest_client(
+ self._setting.metalake,
+ self._setting.gravitino_uri,
+ authorization,
+ )
+ self._clients_by_auth[authorization] = client
+ if len(self._clients_by_auth) > _MAX_CACHED_CLIENTS:
+ _, evicted = self._clients_by_auth.popitem(last=False)
+ self._schedule_close(evicted)
+ return client
+
+ def _schedule_close(self, client) -> None:
+ """Best-effort close of an evicted client's connection pool.
+
+ Closing is async; schedule it on the running event loop if there is one
+ (the normal HTTP-serving case). With no running loop (stdio mode/tests)
+ there is nothing to schedule and the client is left for GC.
+ """
+ close = getattr(client, "close", None)
+ if close is None:
+ return
+ try:
+ loop = asyncio.get_running_loop()
+ except RuntimeError:
+ return
+ task = loop.create_task(close())
+ # Hold a strong reference until the task finishes (see
_pending_closes).
+ self._pending_closes.add(task)
+ task.add_done_callback(self._on_close_done)
+
+ def _on_close_done(self, task: "asyncio.Task") -> None:
+ """Drop the finished close task and log any failure."""
+ self._pending_closes.discard(task)
+ exc = task.exception()
+ if exc is not None:
+ _LOG.warning("Failed to close evicted REST client: %s", exc)
diff --git a/mcp-server/mcp_server/core/setting.py
b/mcp-server/mcp_server/core/setting.py
index 474fdbc6b4..0659c8f602 100644
--- a/mcp-server/mcp_server/core/setting.py
+++ b/mcp-server/mcp_server/core/setting.py
@@ -27,9 +27,28 @@ class DefaultSetting:
@dataclass
-class Setting:
+class Setting: # pylint: disable=too-many-instance-attributes
metalake: str
gravitino_uri: str = DefaultSetting.default_gravitino_uri
tags: Set[str] = field(default_factory=set)
transport: str = DefaultSetting.default_transport
mcp_url: str = DefaultSetting.default_mcp_url
+ # Static OAuth2 Bearer token. Sent on every request in stdio mode; in HTTP
+ # mode it is only the fallback used when an incoming request carries no
+ # Authorization header (per-request identity takes priority).
+ # Empty string means anonymous (no Authorization header sent).
+ # repr=False keeps the raw value out of the dataclass-generated __repr__.
+ token: str = field(default="", repr=False)
+ # TLS certificate/key paths for serving the HTTP endpoint over HTTPS.
+ # Both must be set to enable TLS; empty means plain HTTP.
+ tls_cert: str = ""
+ tls_key: str = ""
+
+ def __str__(self) -> str:
+ token_display = "***" if self.token else ""
+ return (
+ f"Setting(metalake={self.metalake},
gravitino_uri={self.gravitino_uri}, "
+ f"tags={self.tags}, transport={self.transport},
mcp_url={self.mcp_url}, "
+ f"token={token_display}, tls_cert={self.tls_cert}, "
+ f"tls_key={self.tls_key})"
+ )
diff --git a/mcp-server/mcp_server/main.py b/mcp-server/mcp_server/main.py
index 73a5aa265f..903d9d30b0 100644
--- a/mcp-server/mcp_server/main.py
+++ b/mcp-server/mcp_server/main.py
@@ -17,6 +17,7 @@
import argparse
import logging
+import os
from mcp_server.core.setting import DefaultSetting, Setting
from mcp_server.server import GravitinoMCPServer
@@ -30,6 +31,9 @@ def do_main():
tags=args.include_tool_tags,
transport=args.transport,
mcp_url=args.mcp_url,
+ token=args.token,
+ tls_cert=args.tls_cert,
+ tls_key=args.tls_key,
)
_init_logging(setting)
logging.info("Gravitino MCP server setting: %s", setting)
@@ -44,6 +48,12 @@ def _init_logging(setting: Setting):
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
+ # Separate file handler for structured audit records (one JSON line per
entry).
+ audit_handler = logging.FileHandler("gravitino-mcp-audit.log")
+ audit_handler.setLevel(logging.INFO)
+ audit_handler.setFormatter(logging.Formatter("%(message)s"))
+ logging.getLogger("gravitino.mcp.audit").addHandler(audit_handler)
+ logging.getLogger("gravitino.mcp.audit").propagate = False
def _comma_separated_set(value) -> set:
@@ -83,9 +93,10 @@ def _parse_args():
parser.add_argument(
"--transport",
type=str,
- choices=["stdio", "http"],
+ choices=["stdio", "http", "streamable-http"],
default=DefaultSetting.default_transport,
- help=f"Transport protocol type: stdio (local), http (Streamable HTTP).
"
+ help="Transport protocol type: stdio (local), http / streamable-http "
+ "(networked Streamable HTTP; the two names are equivalent). "
f"(default: {DefaultSetting.default_transport})",
)
@@ -93,7 +104,36 @@ def _parse_args():
"--mcp-url",
type=str,
default=DefaultSetting.default_mcp_url,
- help=f"The url of MCP server if using http transport. (default:
{DefaultSetting.default_mcp_url})",
+ help="The url of MCP server if using http transport, http:// or
https://. "
+ f"(default: {DefaultSetting.default_mcp_url})",
+ )
+
+ parser.add_argument(
+ "--token",
+ type=str,
+ default=os.environ.get("GRAVITINO_TOKEN", ""),
+ help="Static OAuth2 Bearer token used to authenticate to Gravitino. "
+ "In stdio mode it is sent on every request; in HTTP mode it is only
the "
+ "fallback when an incoming request carries no Authorization header "
+ "(per-request identity takes priority). "
+ "Can also be set via the GRAVITINO_TOKEN environment variable. "
+ "When omitted, requests are sent without authentication.",
+ )
+
+ parser.add_argument(
+ "--tls-cert",
+ type=str,
+ default="",
+ help="Path to the TLS certificate (PEM) for serving the HTTP endpoint "
+ "over HTTPS. Requires --tls-key. When omitted, the endpoint serves
plain HTTP.",
+ )
+
+ parser.add_argument(
+ "--tls-key",
+ type=str,
+ default="",
+ help="Path to the TLS private key (PEM) for serving the HTTP endpoint "
+ "over HTTPS. Requires --tls-cert.",
)
args = parser.parse_args()
diff --git a/mcp-server/mcp_server/server.py b/mcp-server/mcp_server/server.py
index 9cce5f556b..b8909120da 100644
--- a/mcp-server/mcp_server/server.py
+++ b/mcp-server/mcp_server/server.py
@@ -21,18 +21,71 @@ from contextlib import asynccontextmanager
from typing import AsyncIterator
from urllib.parse import urlparse
+import mcp.types as mt
from fastmcp import FastMCP
from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware
from fastmcp.server.middleware.logging import (
LoggingMiddleware,
)
+from fastmcp.server.middleware.middleware import (
+ CallNext,
+ Middleware,
+ MiddlewareContext,
+)
from fastmcp.server.middleware.timing import TimingMiddleware
+from fastmcp.tools.base import ToolResult
-from mcp_server.core.context import GravitinoContext
+from mcp_server.core import audit
+from mcp_server.core.context import (
+ GravitinoContext,
+ _get_request_authorization,
+ startup_authorization,
+)
from mcp_server.core.setting import Setting
from mcp_server.tools import load_tools
+def _get_principal_from_request(fallback_authorization: str = "") -> str:
+ """Derive a display principal for audit attribution.
+
+ Uses the incoming HTTP request's Authorization header when present;
+ otherwise falls back to the static startup identity (``--token``), which is
+ what actually authenticates the call in stdio mode or in HTTP requests that
+ carry no Authorization header. Returns "anonymous" when neither is set.
+ """
+ authorization = _get_request_authorization() or fallback_authorization
+ # pylint: disable=protected-access
+ return audit._extract_principal(authorization)
+
+
+class AuditMiddleware(Middleware):
+ """Emit a structured audit record for every tool invocation."""
+
+ def __init__(self, fallback_authorization: str = ""):
+ super().__init__()
+ self._fallback_authorization = fallback_authorization
+
+ async def on_call_tool(
+ self,
+ context: MiddlewareContext[mt.CallToolRequestParams],
+ call_next: CallNext[mt.CallToolRequestParams, ToolResult],
+ ) -> ToolResult:
+ tool_name = context.message.name if context.message else "unknown"
+ principal = _get_principal_from_request(self._fallback_authorization)
+ try:
+ result = await call_next(context)
+ audit.emit(principal=principal, tool=tool_name, outcome="allow")
+ return result
+ except Exception as exc:
+ audit.emit(
+ principal=principal,
+ tool=tool_name,
+ outcome="deny",
+ error_type=type(exc).__name__,
+ )
+ raise
+
+
def _create_lifespan_manager(gravitino_context: GravitinoContext):
@asynccontextmanager
@@ -56,10 +109,10 @@ def _create_gravitino_mcp(setting: Setting) -> FastMCP:
lifespan=_create_lifespan_manager(GravitinoContext(setting)),
)
+ mcp.add_middleware(AuditMiddleware(startup_authorization(setting)))
mcp.add_middleware(
LoggingMiddleware(include_payloads=True, max_payload_length=1000)
)
-
mcp.add_middleware(TimingMiddleware())
mcp.add_middleware(
ErrorHandlingMiddleware(
@@ -70,17 +123,20 @@ def _create_gravitino_mcp(setting: Setting) -> FastMCP:
return mcp
-def _parse_mcp_url(url: str) -> ():
+def _parse_mcp_url(url: str) -> tuple[str, int, str]:
try:
parsed = urlparse(url)
- if parsed.scheme.lower() != "http":
- raise ValueError(f"Not support: {parsed.scheme},only support HTTP")
+ scheme = parsed.scheme.lower()
+ if scheme not in ("http", "https"):
+ raise ValueError(
+ f"Not supported: {parsed.scheme}, only http/https are
supported"
+ )
host = parsed.hostname or "0.0.0.0"
port = parsed.port
if port is None:
- port = 80
+ port = 443 if scheme == "https" else 80
path = parsed.path
if not path.startswith("/"):
@@ -109,8 +165,55 @@ class GravitinoMCPServer:
def _run_http(self):
_host, _port, _path = _parse_mcp_url(self.setting.mcp_url)
- asyncio.run(
- self.mcp.run_async(
- transport="http", host=_host, port=_port, path=_path
- )
+ self._validate_tls_config()
+ # FastMCP accepts "http" and "streamable-http" as equivalent aliases.
+ transport = (
+ "streamable-http"
+ if self.setting.transport == "streamable-http"
+ else "http"
)
+
+ run_kwargs = {
+ "transport": transport,
+ "host": _host,
+ "port": _port,
+ "path": _path,
+ }
+
+ # Serve over TLS when both certificate and key are provided. FastMCP
+ # forwards uvicorn_config to the underlying uvicorn server.
+ if self.setting.tls_cert and self.setting.tls_key:
+ run_kwargs["uvicorn_config"] = {
+ "ssl_certfile": self.setting.tls_cert,
+ "ssl_keyfile": self.setting.tls_key,
+ }
+ logging.info(
+ "Serving MCP endpoint over TLS (cert=%s)",
self.setting.tls_cert
+ )
+
+ asyncio.run(self.mcp.run_async(**run_kwargs))
+
+ def _validate_tls_config(self):
+ """Reject inconsistent TLS configuration before starting the server.
+
+ Guards against serving plain HTTP on an ``https://`` URL (or TLS behind
+ an ``http://`` URL), and against providing only one of cert/key.
+ """
+ cert, key = self.setting.tls_cert, self.setting.tls_key
+ if bool(cert) != bool(key):
+ raise ValueError(
+ "Both --tls-cert and --tls-key must be provided together "
+ "(or neither)."
+ )
+ scheme = urlparse(self.setting.mcp_url).scheme.lower()
+ tls_enabled = bool(cert and key)
+ if scheme == "https" and not tls_enabled:
+ raise ValueError(
+ f"mcp_url '{self.setting.mcp_url}' uses https but TLS is not "
+ "configured; provide --tls-cert and --tls-key."
+ )
+ if scheme == "http" and tls_enabled:
+ raise ValueError(
+ "--tls-cert/--tls-key are set but mcp_url "
+ f"'{self.setting.mcp_url}' uses http; use an https URL."
+ )
diff --git a/mcp-server/pyproject.toml b/mcp-server/pyproject.toml
index 5966f88b54..958bada491 100644
--- a/mcp-server/pyproject.toml
+++ b/mcp-server/pyproject.toml
@@ -34,6 +34,13 @@ dependencies = [
"pylint>=2.20.0",
]
+# Restrict packaging to the importable library. Without this, setuptools'
+# flat-layout auto-discovery also picks up sibling dirs like `dev/` (it only
+# excludes `tests*` by default), failing the build with
+# "Multiple top-level packages discovered in a flat-layout".
+[tool.setuptools.packages.find]
+include = ["mcp_server", "mcp_server.*"]
+
[tool.isort]
profile = "black"
line_length = 80
diff --git a/mcp-server/mcp_server/core/context.py
b/mcp-server/tests/integration/__init__.py
similarity index 68%
copy from mcp-server/mcp_server/core/context.py
copy to mcp-server/tests/integration/__init__.py
index 6e540a0b51..13a83393a9 100644
--- a/mcp-server/mcp_server/core/context.py
+++ b/mcp-server/tests/integration/__init__.py
@@ -14,16 +14,3 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-
-from mcp_server.client.factory import RESTClientFactory
-from mcp_server.core.setting import Setting
-
-
-class GravitinoContext:
- def __init__(self, setting: Setting):
- self.gravitino_client = RESTClientFactory.create_rest_client(
- setting.metalake, setting.gravitino_uri
- )
-
- def rest_client(self):
- return self.gravitino_client
diff --git a/mcp-server/tests/integration/conftest.py
b/mcp-server/tests/integration/conftest.py
new file mode 100644
index 0000000000..6dda99faea
--- /dev/null
+++ b/mcp-server/tests/integration/conftest.py
@@ -0,0 +1,73 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""pytest fixtures for the MCP authorization integration test.
+
+The orchestration script (``dev/run_authz_integration_test.sh``) starts a real
+Gravitino server and the MCP server in HTTP mode, then exports the connection
+details below. When these env vars are absent the whole integration suite is
+skipped so a plain ``pytest`` run stays green without external services.
+
+Required environment variables:
+ GRAVITINO_URI e.g. http://localhost:8090
+ MCP_URL e.g. http://localhost:8000/mcp
+ MCP_METALAKE metalake name the MCP server was launched against
+"""
+
+import os
+
+import pytest
+
+from tests.integration.gravitino_setup import GravitinoFixture
+
+# Fixtures receive other fixtures as same-named parameters; this is the
standard
+# pytest pattern, not an accidental shadowing.
+# pylint: disable=redefined-outer-name
+
+_REQUIRED_ENV = ("GRAVITINO_URI", "MCP_URL", "MCP_METALAKE")
+
+
+def _missing_env() -> list:
+ return [name for name in _REQUIRED_ENV if not os.environ.get(name)]
+
+
[email protected](scope="session")
+def integration_env() -> dict:
+ missing = _missing_env()
+ if missing:
+ pytest.skip(
+ "Integration test requires a running Gravitino + MCP server. "
+ f"Missing env: {', '.join(missing)}. "
+ "Run via dev/run_authz_integration_test.sh."
+ )
+ return {
+ "gravitino_uri": os.environ["GRAVITINO_URI"],
+ "mcp_url": os.environ["MCP_URL"],
+ "metalake": os.environ["MCP_METALAKE"],
+ }
+
+
[email protected](scope="session")
+def gravitino_fixture(integration_env: dict) -> GravitinoFixture:
+ """Provision metalake/catalogs/user/role/grant once for the whole suite."""
+ fixture = GravitinoFixture(
+ gravitino_uri=integration_env["gravitino_uri"],
+ metalake=integration_env["metalake"],
+ )
+ fixture.provision()
+ yield fixture
+ fixture.close()
diff --git a/mcp-server/tests/integration/gravitino_setup.py
b/mcp-server/tests/integration/gravitino_setup.py
new file mode 100644
index 0000000000..38510ac2c1
--- /dev/null
+++ b/mcp-server/tests/integration/gravitino_setup.py
@@ -0,0 +1,145 @@
+# 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.
+
+"""Provision Gravitino metadata and authorization fixtures for the integration
test.
+
+All requests are issued as the service admin using Gravitino simple
+authentication (``Authorization: Basic base64(user:dummy)``). The fixture
+creates a metalake with two model catalogs, a non-admin user ``bob``, and a
role
+that grants ``bob`` access to only one of the two catalogs. This produces a
+visibly different authorization slice between the admin and ``bob`` principals.
+"""
+
+import base64
+
+import httpx
+
+
+def basic_auth_header(user: str) -> str:
+ """Build a Gravitino simple-auth header value for ``user``."""
+ credential = base64.b64encode(f"{user}:dummy".encode("utf-8")).decode(
+ "ascii"
+ )
+ return f"Basic {credential}"
+
+
+class GravitinoFixture: # pylint: disable=too-many-instance-attributes
+ """Sets up metalake/catalogs/user/role/grant via the Gravitino REST API."""
+
+ def __init__( # pylint:
disable=too-many-positional-arguments,too-many-arguments
+ self,
+ gravitino_uri: str,
+ metalake: str,
+ admin_user: str = "admin",
+ granted_user: str = "bob",
+ catalog_allowed: str = "cat_allowed",
+ catalog_denied: str = "cat_denied",
+ role_name: str = "reader_role",
+ ):
+ self.gravitino_uri = gravitino_uri.rstrip("/")
+ self.metalake = metalake
+ self.admin_user = admin_user
+ self.granted_user = granted_user
+ self.catalog_allowed = catalog_allowed
+ self.catalog_denied = catalog_denied
+ self.role_name = role_name
+ self._client = httpx.Client(
+ base_url=self.gravitino_uri,
+ headers={"Authorization": basic_auth_header(admin_user)},
+ timeout=30.0,
+ )
+
+ def _post(self, path: str, body: dict) -> httpx.Response:
+ response = self._client.post(path, json=body)
+ response.raise_for_status()
+ return response
+
+ def _put(self, path: str, body: dict) -> httpx.Response:
+ response = self._client.put(path, json=body)
+ response.raise_for_status()
+ return response
+
+ def provision(self) -> None:
+ """Create all metadata and authorization fixtures.
+
+ Not idempotent: every step raises on a non-2xx response, so re-running
+ against an already-provisioned metalake fails (e.g. HTTP 409). Expects
a
+ clean Gravitino instance.
+ """
+ self._create_metalake()
+ self._create_model_catalog(self.catalog_allowed)
+ self._create_model_catalog(self.catalog_denied)
+ self._add_user(self.granted_user)
+ self._create_reader_role()
+ self._grant_role_to_user()
+
+ def _create_metalake(self) -> None:
+ self._post(
+ "/api/metalakes",
+ {
+ "name": self.metalake,
+ "comment": "MCP authz integration test metalake",
+ "properties": {},
+ },
+ )
+
+ def _create_model_catalog(self, name: str) -> None:
+ self._post(
+ f"/api/metalakes/{self.metalake}/catalogs",
+ {
+ "name": name,
+ "type": "MODEL",
+ "provider": "model",
+ "comment": "model catalog for authz test",
+ "properties": {},
+ },
+ )
+
+ def _add_user(self, user: str) -> None:
+ self._post(
+ f"/api/metalakes/{self.metalake}/users",
+ {"name": user},
+ )
+
+ def _create_reader_role(self) -> None:
+ # Grant bob USE_CATALOG on the allowed catalog only.
+ self._post(
+ f"/api/metalakes/{self.metalake}/roles",
+ {
+ "name": self.role_name,
+ "properties": {},
+ "securableObjects": [
+ {
+ "fullName": self.catalog_allowed,
+ "type": "CATALOG",
+ "privileges": [
+ {"name": "USE_CATALOG", "condition": "ALLOW"}
+ ],
+ }
+ ],
+ },
+ )
+
+ def _grant_role_to_user(self) -> None:
+ self._put(
+ f"/api/metalakes/{self.metalake}/permissions"
+ f"/users/{self.granted_user}/grant/",
+ {"roleNames": [self.role_name]},
+ )
+
+ def close(self) -> None:
+ self._client.close()
diff --git a/mcp-server/tests/integration/test_authz_e2e.py
b/mcp-server/tests/integration/test_authz_e2e.py
new file mode 100644
index 0000000000..488e6245db
--- /dev/null
+++ b/mcp-server/tests/integration/test_authz_e2e.py
@@ -0,0 +1,175 @@
+# 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.
+
+"""End-to-end authorization integration test through the live MCP HTTP server.
+
+Validates the three acceptance scenarios against a real Gravitino with
+authorization enabled:
+
+ 1. Two principals run the same discovery call and get correctly different,
+ authorization-scoped results.
+ 2. A read-only principal attempts a write through MCP and is denied by
+ Gravitino authorization.
+ 3. Both the reads and the denied write appear as audit records attributed to
+ the correct principal.
+
+These tests only run when GRAVITINO_URI / MCP_URL / MCP_METALAKE are set (see
+conftest.py); otherwise the suite is skipped.
+"""
+
+import asyncio
+import json
+import os
+import time
+
+import pytest
+from fastmcp import Client
+from fastmcp.client.transports import StreamableHttpTransport
+
+from tests.integration.gravitino_setup import basic_auth_header
+
+ADMIN = "admin"
+BOB = "bob"
+CATALOG_ALLOWED = "cat_allowed"
+CATALOG_DENIED = "cat_denied"
+
+
+def _client_for(principal: str, mcp_url: str) -> Client:
+ """Build an MCP client that authenticates as ``principal`` (simple
auth)."""
+ transport = StreamableHttpTransport(
+ url=mcp_url,
+ headers={"Authorization": basic_auth_header(principal)},
+ )
+ return Client(transport)
+
+
+async def _list_catalog_names(principal: str, mcp_url: str) -> set:
+ async with _client_for(principal, mcp_url) as client:
+ result = await client.call_tool("get_list_of_catalogs")
+ payload = json.loads(result.content[0].text)
+ return {entry["name"] for entry in payload}
+
+
+def test_authorization_scoped_discovery(gravitino_fixture, integration_env):
+ """Admin and bob get different, correctly scoped catalog lists."""
+ mcp_url = integration_env["mcp_url"]
+
+ admin_catalogs = asyncio.run(_list_catalog_names(ADMIN, mcp_url))
+ bob_catalogs = asyncio.run(_list_catalog_names(BOB, mcp_url))
+
+ # Admin owns the metalake and sees both catalogs.
+ assert CATALOG_ALLOWED in admin_catalogs
+ assert CATALOG_DENIED in admin_catalogs
+
+ # Bob was granted USE_CATALOG on the allowed catalog only.
+ assert CATALOG_ALLOWED in bob_catalogs
+ assert CATALOG_DENIED not in bob_catalogs
+
+ # The two principals must receive different results.
+ assert admin_catalogs != bob_catalogs
+
+
+def test_write_denied_for_readonly_principal(
+ gravitino_fixture, integration_env
+):
+ """Bob (no write grant) is denied when creating a tag through MCP."""
+ mcp_url = integration_env["mcp_url"]
+
+ async def _attempt_write():
+ async with _client_for(BOB, mcp_url) as client:
+ await client.call_tool(
+ "create_tag",
+ {
+ "name": "denied_tag",
+ "comment": "should be denied",
+ "properties": {},
+ },
+ )
+
+ with pytest.raises(Exception) as exc_info: # noqa: B017
+ asyncio.run(_attempt_write())
+
+ # The failure must be an authorization denial, not an unrelated error
+ # (transport failure, missing tool, server crash, ...).
+ message = str(exc_info.value).lower()
+ assert any(
+ token in message
+ for token in (
+ "forbidden",
+ "unauthorized",
+ "not authorized",
+ "permission",
+ "denied",
+ "access",
+ "403",
+ )
+ ), f"expected an authorization denial, got: {exc_info.value!r}"
+
+
+def test_audit_trail_attribution(gravitino_fixture, integration_env):
+ """Audit log records reads/writes attributed to the right principal."""
+ audit_log = os.environ.get("MCP_AUDIT_LOG")
+ if not audit_log or not os.path.exists(audit_log):
+ pytest.skip("MCP_AUDIT_LOG not set or file missing")
+
+ mcp_url = integration_env["mcp_url"]
+
+ async def _attempt_write():
+ async with _client_for(BOB, mcp_url) as client:
+ await client.call_tool(
+ "create_tag",
+ {"name": "audit_tag", "comment": "", "properties": {}},
+ )
+
+ # Generate one allowed read (admin) and one denied write (bob).
+ asyncio.run(_list_catalog_names(ADMIN, mcp_url))
+ try:
+ asyncio.run(_attempt_write())
+ except Exception: # pylint: disable=broad-exception-caught
+ # Expected denial for an unauthorized principal.
+ pass
+
+ # Give the server a moment to flush the audit handler.
+ time.sleep(1.0)
+
+ records = []
+ with open(audit_log, "r", encoding="utf-8") as fh:
+ for line in fh:
+ line = line.strip()
+ if line:
+ try:
+ records.append(json.loads(line))
+ except json.JSONDecodeError:
+ continue
+
+ admin_allows = [
+ r
+ for r in records
+ if r.get("principal") == ADMIN
+ and r.get("tool") == "get_list_of_catalogs"
+ and r.get("outcome") == "allow"
+ ]
+ bob_denies = [
+ r
+ for r in records
+ if r.get("principal") == BOB
+ and r.get("tool") == "create_tag"
+ and r.get("outcome") == "deny"
+ ]
+
+ assert admin_allows, "expected an allow record attributed to admin"
+ assert bob_denies, "expected a deny record attributed to bob"
diff --git a/mcp-server/tests/unit/test_audit.py
b/mcp-server/tests/unit/test_audit.py
new file mode 100644
index 0000000000..b338bab890
--- /dev/null
+++ b/mcp-server/tests/unit/test_audit.py
@@ -0,0 +1,232 @@
+# 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.
+
+import asyncio
+import json
+import logging
+import unittest
+
+from fastmcp import Client
+
+from mcp_server.client.factory import RESTClientFactory
+from mcp_server.client.plain.exception import GravitinoException
+from mcp_server.client.plain.plain_rest_client_operation import (
+ PlainRESTClientOperation,
+)
+from mcp_server.core import audit
+from mcp_server.core.setting import Setting
+from mcp_server.server import GravitinoMCPServer
+from tests.unit.tools import MockOperation
+
+# Tests intentionally exercise module-private helpers (e.g.
audit._extract_principal)
+# and client internals; protected access is expected here.
+# pylint: disable=protected-access
+
+
+class TestAuditEmit(unittest.TestCase):
+ """Unit tests for the audit.emit() function."""
+
+ def setUp(self):
+ # tests/unit/tools/__init__.py calls logging.disable(logging.INFO).
+ # Re-enable here so audit records flow through; restore on teardown.
+ logging.disable(logging.NOTSET)
+ self.log_records = []
+ self.handler = _CapturingHandler(self.log_records)
+ audit_logger = logging.getLogger("gravitino.mcp.audit")
+ audit_logger.addHandler(self.handler)
+ audit_logger.setLevel(logging.INFO)
+ audit_logger.propagate = False
+
+ def tearDown(self):
+ logging.disable(logging.INFO)
+ audit_logger = logging.getLogger("gravitino.mcp.audit")
+ audit_logger.removeHandler(self.handler)
+ audit_logger.propagate = True
+
+ def test_allow_record_structure(self):
+ """emit() writes a JSON record with all required fields on allow."""
+ audit.emit(
+ principal="bearer:abc12345", tool="list_catalogs", outcome="allow"
+ )
+
+ self.assertEqual(len(self.log_records), 1)
+ record = json.loads(self.log_records[0])
+ self.assertEqual(record["principal"], "bearer:abc12345")
+ self.assertEqual(record["tool"], "list_catalogs")
+ self.assertEqual(record["outcome"], "allow")
+ self.assertIn("timestamp", record)
+ self.assertNotIn("error_type", record)
+
+ def test_deny_record_includes_error_type(self):
+ """emit() includes error_type in the record when outcome is deny."""
+ audit.emit(
+ principal="bearer:xyz99999",
+ tool="create_tag",
+ outcome="deny",
+ error_type="GravitinoException",
+ )
+
+ record = json.loads(self.log_records[0])
+ self.assertEqual(record["outcome"], "deny")
+ self.assertEqual(record["error_type"], "GravitinoException")
+
+ def test_anonymous_principal(self):
+ """emit() works with anonymous principal."""
+ audit.emit(
+ principal="anonymous", tool="get_list_of_catalogs", outcome="allow"
+ )
+ record = json.loads(self.log_records[0])
+ self.assertEqual(record["principal"], "anonymous")
+
+
+class TestExtractPrincipal(unittest.TestCase):
+ """Unit tests for audit._extract_principal()."""
+
+ def test_bearer_token_truncated_to_8_chars(self):
+ self.assertEqual(
+ audit._extract_principal("Bearer abcdefghijklmnop"),
+ "bearer:abcdefgh",
+ )
+
+ def test_empty_header_returns_anonymous(self):
+ self.assertEqual(audit._extract_principal(""), "anonymous")
+
+ def test_none_like_empty_returns_anonymous(self):
+ self.assertEqual(audit._extract_principal(None), "anonymous")
+
+ def test_short_token_uses_full_token(self):
+ self.assertEqual(audit._extract_principal("Bearer abc"), "bearer:abc")
+
+ def test_basic_auth_decodes_user(self):
+ """Simple auth header 'Basic base64(alice:dummy)' -> principal
'alice'."""
+ # base64("alice:dummy") == "YWxpY2U6ZHVtbXk="
+ self.assertEqual(
+ audit._extract_principal("Basic YWxpY2U6ZHVtbXk="), "alice"
+ )
+
+ def test_basic_auth_invalid_base64_returns_anonymous(self):
+ self.assertEqual(
+ audit._extract_principal("Basic not-valid-base64!!"), "anonymous"
+ )
+
+ def test_unknown_scheme_returns_anonymous(self):
+ self.assertEqual(
+ audit._extract_principal("Negotiate abc123"), "anonymous"
+ )
+
+
+class TestAuditMiddlewareIntegration(unittest.TestCase):
+ """Integration tests: AuditMiddleware emits records via the full MCP tool
path."""
+
+ def setUp(self):
+ logging.disable(logging.NOTSET)
+ self.log_records = []
+ self.handler = _CapturingHandler(self.log_records)
+ audit_logger = logging.getLogger("gravitino.mcp.audit")
+ audit_logger.addHandler(self.handler)
+ audit_logger.setLevel(logging.INFO)
+ audit_logger.propagate = False
+
+ RESTClientFactory.set_rest_client(MockOperation)
+ server = GravitinoMCPServer(Setting("mock_metalake"))
+ self.mcp = server.mcp
+
+ def tearDown(self):
+ logging.disable(logging.INFO)
+ audit_logger = logging.getLogger("gravitino.mcp.audit")
+ audit_logger.removeHandler(self.handler)
+ audit_logger.propagate = True
+ # Restore original REST client so other tests are not affected.
+ RESTClientFactory.set_rest_client(PlainRESTClientOperation)
+
+ def test_successful_tool_call_emits_allow_record(self):
+ """A successful tool call produces an audit record with
outcome=allow."""
+
+ async def _run():
+ async with Client(self.mcp) as client:
+ await client.call_tool("get_list_of_catalogs")
+
+ asyncio.run(_run())
+
+ self.assertEqual(len(self.log_records), 1)
+ record = json.loads(self.log_records[0])
+ self.assertEqual(record["tool"], "get_list_of_catalogs")
+ self.assertEqual(record["outcome"], "allow")
+ self.assertEqual(record["principal"], "anonymous")
+
+ def test_principal_falls_back_to_startup_token(self):
+ """With no request header, the audit principal uses the startup
--token."""
+ RESTClientFactory.set_rest_client(MockOperation)
+ server = GravitinoMCPServer(
+ Setting("mock_metalake", token="abcdef123456")
+ )
+
+ async def _run():
+ async with Client(server.mcp) as client:
+ await client.call_tool("get_list_of_catalogs")
+
+ asyncio.run(_run())
+
+ record = json.loads(self.log_records[0])
+ self.assertEqual(record["principal"], "bearer:abcdef12")
+
+ def test_failed_tool_call_emits_deny_record(self):
+ """A tool call that raises an exception produces an audit record with
outcome=deny."""
+
+ class FailingOperation(MockOperation):
+ def as_catalog_operation(self):
+ return _FailingCatalogOperation()
+
+ RESTClientFactory.set_rest_client(FailingOperation)
+ server = GravitinoMCPServer(Setting("mock_metalake"))
+
+ async def _run():
+ async with Client(server.mcp) as client:
+ try:
+ await client.call_tool("get_list_of_catalogs")
+ except Exception: # pylint: disable=broad-exception-caught
+ pass
+
+ asyncio.run(_run())
+
+ deny_records = [
+ json.loads(r) for r in self.log_records if '"deny"' in r
+ ]
+ self.assertTrue(len(deny_records) >= 1)
+ self.assertEqual(deny_records[0]["tool"], "get_list_of_catalogs")
+ self.assertEqual(deny_records[0]["outcome"], "deny")
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+class _CapturingHandler(logging.Handler):
+ """Logging handler that stores formatted messages in a list."""
+
+ def __init__(self, records: list):
+ super().__init__()
+ self._records = records
+
+ def emit(self, record: logging.LogRecord) -> None:
+ self._records.append(self.format(record))
+
+
+class _FailingCatalogOperation:
+ async def get_list_of_catalogs(self) -> str:
+ raise GravitinoException("Error code: 1003, Error type: FORBIDDEN")
diff --git a/mcp-server/tests/unit/test_auth_flow.py
b/mcp-server/tests/unit/test_auth_flow.py
new file mode 100644
index 0000000000..b596654d84
--- /dev/null
+++ b/mcp-server/tests/unit/test_auth_flow.py
@@ -0,0 +1,192 @@
+# 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.
+
+import asyncio
+import sys
+import unittest
+from unittest import mock
+
+from mcp_server.client.factory import RESTClientFactory
+from mcp_server.client.plain.plain_rest_client_operation import (
+ PlainRESTClientOperation,
+)
+from mcp_server.core.context import GravitinoContext
+from mcp_server.core.setting import Setting
+from mcp_server.main import _parse_args
+
+
+class _RealFactoryTestCase(unittest.TestCase):
+ """Base for tests that inspect the real PlainRESTClientOperation.
+
+ Other test modules globally swap RESTClientFactory to MockOperation without
+ restoring it, so pin the real client here to stay order-independent.
+ """
+
+ def setUp(self):
+ RESTClientFactory.set_rest_client(PlainRESTClientOperation)
+
+
+def _shared_rest_client(operation: PlainRESTClientOperation):
+ # pylint: disable=protected-access
+ return operation._catalog_operation.rest_client
+
+
+def _headers_of(operation: PlainRESTClientOperation):
+ return _shared_rest_client(operation).headers
+
+
+def _close(operation: PlainRESTClientOperation):
+ asyncio.run(_shared_rest_client(operation).aclose())
+
+
+class TestAuthorizationInjection(_RealFactoryTestCase):
+ """Verify the Authorization header is forwarded verbatim to the httpx
client."""
+
+ def test_bearer_authorization_header(self):
+ """A Bearer authorization value is forwarded unchanged."""
+ client = PlainRESTClientOperation(
+ "my_metalake",
+ "http://localhost:8090",
+ authorization="Bearer my-secret-token",
+ )
+ try:
+ self.assertEqual(
+ _headers_of(client).get("Authorization"),
+ "Bearer my-secret-token",
+ )
+ finally:
+ _close(client)
+
+ def test_basic_authorization_header(self):
+ """A Basic authorization value (simple auth) is forwarded unchanged."""
+ client = PlainRESTClientOperation(
+ "my_metalake",
+ "http://localhost:8090",
+ authorization="Basic YWxpY2U6ZHVtbXk=",
+ )
+ try:
+ self.assertEqual(
+ _headers_of(client).get("Authorization"),
+ "Basic YWxpY2U6ZHVtbXk=",
+ )
+ finally:
+ _close(client)
+
+ def test_empty_authorization_no_header(self):
+ """When authorization is empty, no Authorization header is added."""
+ client = PlainRESTClientOperation(
+ "my_metalake", "http://localhost:8090", authorization=""
+ )
+ try:
+ self.assertIsNone(_headers_of(client).get("Authorization"))
+ finally:
+ _close(client)
+
+ def test_no_authorization_argument_no_header(self):
+ """When authorization argument is omitted, no Authorization header is
added."""
+ client = PlainRESTClientOperation(
+ "my_metalake", "http://localhost:8090"
+ )
+ try:
+ self.assertIsNone(_headers_of(client).get("Authorization"))
+ finally:
+ _close(client)
+
+
+class TestSettingTokenMasking(unittest.TestCase):
+ """Verify that the token is not exposed in Setting string
representations."""
+
+ def test_token_masked_in_str(self):
+ """Token value must not appear in Setting.__str__."""
+ setting = Setting(metalake="ml", token="super-secret-token-value")
+ self.assertNotIn("super-secret-token-value", str(setting))
+ self.assertIn("***", str(setting))
+
+ def test_token_not_in_repr(self):
+ """Token value must not appear in Setting.__repr__ either."""
+ setting = Setting(metalake="ml", token="super-secret-token-value")
+ self.assertNotIn("super-secret-token-value", repr(setting))
+
+ def test_empty_token_shows_empty_in_str(self):
+ """When no token is set, __str__ shows empty placeholder."""
+ setting = Setting(metalake="ml", token="")
+ self.assertNotIn("***", str(setting))
+
+
+class TestTokenArgParsing(unittest.TestCase):
+ """Verify --token CLI argument and GRAVITINO_TOKEN env var precedence."""
+
+ def test_env_var_used_when_token_omitted(self):
+ """GRAVITINO_TOKEN is used when --token is not passed."""
+ with mock.patch.dict(
+ "os.environ", {"GRAVITINO_TOKEN": "env-token"}
+ ), mock.patch.object(sys, "argv", ["prog", "--metalake", "ml"]):
+ args = _parse_args()
+ self.assertEqual(args.token, "env-token")
+
+ def test_cli_token_overrides_env_var(self):
+ """--token takes precedence over GRAVITINO_TOKEN when both are set."""
+ with mock.patch.dict(
+ "os.environ", {"GRAVITINO_TOKEN": "env-token"}
+ ), mock.patch.object(
+ sys, "argv", ["prog", "--metalake", "ml", "--token", "cli-token"]
+ ):
+ args = _parse_args()
+ self.assertEqual(args.token, "cli-token")
+
+ def test_no_token_anywhere_defaults_to_empty(self):
+ """Without --token and GRAVITINO_TOKEN, token defaults to empty
string."""
+ with mock.patch.dict("os.environ", {}, clear=True), mock.patch.object(
+ sys, "argv", ["prog", "--metalake", "ml"]
+ ):
+ args = _parse_args()
+ self.assertEqual(args.token, "")
+
+
+class TestGravitinoContextTokenPropagation(_RealFactoryTestCase):
+ """Verify GravitinoContext passes token from Setting to the REST client."""
+
+ def test_context_propagates_token(self):
+ """Token from Setting reaches the httpx client Authorization header."""
+ setting = Setting(
+ metalake="ml",
+ gravitino_uri="http://localhost:8090",
+ token="ctx-token-xyz",
+ )
+ ctx = GravitinoContext(setting)
+ rest_client = ctx.rest_client()
+ try:
+ self.assertEqual(
+ _headers_of(rest_client).get("Authorization"),
+ "Bearer ctx-token-xyz",
+ )
+ finally:
+ _close(rest_client)
+
+ def test_context_anonymous_when_no_token(self):
+ """Empty token in Setting → no Authorization header in REST calls."""
+ setting = Setting(
+ metalake="ml",
+ gravitino_uri="http://localhost:8090",
+ token="",
+ )
+ ctx = GravitinoContext(setting)
+ rest_client = ctx.rest_client()
+ try:
+ self.assertIsNone(_headers_of(rest_client).get("Authorization"))
+ finally:
+ _close(rest_client)
diff --git a/mcp-server/tests/unit/test_per_request_token.py
b/mcp-server/tests/unit/test_per_request_token.py
new file mode 100644
index 0000000000..db9b4ad92c
--- /dev/null
+++ b/mcp-server/tests/unit/test_per_request_token.py
@@ -0,0 +1,242 @@
+# 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.
+
+"""Tests for per-request identity isolation (Task 6).
+
+GravitinoContext.rest_client() must forward the current HTTP request's raw
+Authorization header (any scheme) to Gravitino, not the shared startup token,
+so concurrent multi-principal sessions stay fully isolated in HTTP mode.
+"""
+
+import asyncio
+import unittest
+from unittest.mock import MagicMock, patch
+
+from mcp_server.client.factory import RESTClientFactory
+from mcp_server.client.plain.plain_rest_client_operation import (
+ PlainRESTClientOperation,
+)
+from mcp_server.core import context as context_module
+from mcp_server.core.context import (
+ GravitinoContext,
+ _get_request_authorization,
+)
+from mcp_server.core.setting import Setting
+
+# Tests intentionally exercise context/client internals (e.g. _default_client,
+# _catalog_operation) to assert per-request isolation; protected access is
expected.
+# pylint: disable=protected-access
+
+
+class TestGetRequestAuthorization(unittest.TestCase):
+ """Unit tests for _get_request_authorization() (HTTP context
extraction)."""
+
+ def test_returns_raw_bearer_header(self):
+ mock_request = MagicMock()
+ mock_request.headers.get.return_value = "Bearer request-token-xyz"
+
+ with patch(
+ "fastmcp.server.dependencies.get_http_request",
+ return_value=mock_request,
+ ):
+ authorization = _get_request_authorization()
+
+ self.assertEqual(authorization, "Bearer request-token-xyz")
+
+ def test_returns_raw_basic_header_verbatim(self):
+ """Basic (simple auth) headers must pass through unchanged, not be
dropped."""
+ mock_request = MagicMock()
+ mock_request.headers.get.return_value = "Basic YWxpY2U6ZHVtbXk="
+
+ with patch(
+ "fastmcp.server.dependencies.get_http_request",
+ return_value=mock_request,
+ ):
+ authorization = _get_request_authorization()
+
+ self.assertEqual(authorization, "Basic YWxpY2U6ZHVtbXk=")
+
+ def test_returns_empty_when_no_http_context(self):
+ """Simulates stdio mode where get_http_request raises LookupError."""
+ with patch(
+ "fastmcp.server.dependencies.get_http_request",
+ side_effect=LookupError("no request context"),
+ ):
+ authorization = _get_request_authorization()
+
+ self.assertEqual(authorization, "")
+
+ def test_returns_empty_when_no_authorization_header(self):
+ mock_request = MagicMock()
+ mock_request.headers.get.return_value = ""
+
+ with patch(
+ "fastmcp.server.dependencies.get_http_request",
+ return_value=mock_request,
+ ):
+ authorization = _get_request_authorization()
+
+ self.assertEqual(authorization, "")
+
+
+class TestGravitinoContextPerRequestAuthorization(unittest.TestCase):
+ """GravitinoContext.rest_client() isolates per-request identities."""
+
+ def setUp(self):
+ # These tests inspect the real PlainRESTClientOperation; other test
+ # modules swap the factory to MockOperation without restoring it, so
pin
+ # the real client here to stay order-independent.
+ RESTClientFactory.set_rest_client(PlainRESTClientOperation)
+
+ def _make_context(self, startup_token: str = "") -> GravitinoContext:
+ return GravitinoContext(
+ Setting(
+ metalake="ml",
+ gravitino_uri="http://localhost:8090",
+ token=startup_token,
+ )
+ )
+
+ def test_per_request_header_overrides_startup_token(self):
+ """An HTTP request's Authorization header takes priority over the
startup token."""
+ ctx = self._make_context(startup_token="startup-token")
+
+ mock_request = MagicMock()
+ mock_request.headers.get.return_value = "Basic YWxpY2U6ZHVtbXk="
+
+ with patch(
+ "fastmcp.server.dependencies.get_http_request",
+ return_value=mock_request,
+ ):
+ client = ctx.rest_client()
+
+ headers = dict(client._catalog_operation.rest_client.headers)
+ self.assertEqual(headers.get("authorization"), "Basic
YWxpY2U6ZHVtbXk=")
+
+ def test_falls_back_to_default_client_when_no_request_header(self):
+ """With no per-request header, the shared default client (startup
token) is used."""
+ ctx = self._make_context(startup_token="startup-token")
+
+ with patch(
+ "fastmcp.server.dependencies.get_http_request",
+ side_effect=LookupError,
+ ):
+ client = ctx.rest_client()
+
+ # Must be the exact same object as the cached default client.
+ self.assertIs(client, ctx._default_client)
+
+ def test_two_concurrent_requests_get_different_clients(self):
+ """Different request identities must produce different client
instances."""
+ ctx = self._make_context()
+
+ def make_mock(authorization: str) -> MagicMock:
+ m = MagicMock()
+ m.headers.get.return_value = authorization
+ return m
+
+ with patch(
+ "fastmcp.server.dependencies.get_http_request",
+ return_value=make_mock("Basic YWxpY2U6ZHVtbXk="),
+ ):
+ client_alice = ctx.rest_client()
+
+ with patch(
+ "fastmcp.server.dependencies.get_http_request",
+ return_value=make_mock("Basic Ym9iOmR1bW15"),
+ ):
+ client_bob = ctx.rest_client()
+
+ alice_headers = dict(
+ client_alice._catalog_operation.rest_client.headers
+ )
+ bob_headers = dict(client_bob._catalog_operation.rest_client.headers)
+ self.assertEqual(
+ alice_headers.get("authorization"), "Basic YWxpY2U6ZHVtbXk="
+ )
+ self.assertEqual(bob_headers.get("authorization"), "Basic
Ym9iOmR1bW15")
+ self.assertIsNot(client_alice, client_bob)
+
+ def test_same_principal_reuses_cached_client(self):
+ """Repeated calls from the same principal reuse one cached client."""
+ ctx = self._make_context()
+
+ mock_request = MagicMock()
+ mock_request.headers.get.return_value = "Basic YWxpY2U6ZHVtbXk="
+
+ with patch(
+ "fastmcp.server.dependencies.get_http_request",
+ return_value=mock_request,
+ ):
+ first = ctx.rest_client()
+ second = ctx.rest_client()
+
+ # Same Authorization header -> same client object (connection pool
reused).
+ self.assertIs(first, second)
+
+ def test_client_cache_is_bounded(self):
+ """The per-principal client cache evicts the oldest entries past its
cap."""
+ ctx = self._make_context()
+ cap = context_module._MAX_CACHED_CLIENTS
+
+ for i in range(cap + 5):
+ mock_request = MagicMock()
+ mock_request.headers.get.return_value = f"Bearer token-{i}"
+ with patch(
+ "fastmcp.server.dependencies.get_http_request",
+ return_value=mock_request,
+ ):
+ ctx.rest_client()
+
+ self.assertLessEqual(len(ctx._clients_by_auth), cap)
+
+ def test_evicted_client_is_closed(self):
+ """On eviction (with a running loop) the evicted client's pool is
closed."""
+ closed = []
+
+ class _ClosableClient:
+ def __init__(self, *_args, **_kwargs):
+ pass
+
+ async def close(self):
+ closed.append(self)
+
+ RESTClientFactory.set_rest_client(_ClosableClient)
+ try:
+ ctx = self._make_context()
+ cap = context_module._MAX_CACHED_CLIENTS
+
+ async def _drive():
+ for i in range(cap + 1):
+ mock_request = MagicMock()
+ mock_request.headers.get.return_value = f"Bearer t-{i}"
+ with patch(
+ "fastmcp.server.dependencies.get_http_request",
+ return_value=mock_request,
+ ):
+ ctx.rest_client()
+ # Let the scheduled close task run to completion.
+ await asyncio.sleep(0)
+ await asyncio.gather(*ctx._pending_closes)
+
+ asyncio.run(_drive())
+
+ # Exactly one client (the oldest) was evicted and closed.
+ self.assertEqual(len(closed), 1)
+ self.assertEqual(len(ctx._pending_closes), 0)
+ finally:
+ RESTClientFactory.set_rest_client(PlainRESTClientOperation)
diff --git a/mcp-server/tests/unit/test_transport_tls.py
b/mcp-server/tests/unit/test_transport_tls.py
new file mode 100644
index 0000000000..a7cba980ba
--- /dev/null
+++ b/mcp-server/tests/unit/test_transport_tls.py
@@ -0,0 +1,153 @@
+# 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.
+
+"""Tests for HTTP transport URL parsing, the streamable-http alias, and TLS
wiring."""
+
+import unittest
+from unittest.mock import patch
+
+from mcp_server.client.factory import RESTClientFactory
+from mcp_server.client.plain.plain_rest_client_operation import (
+ PlainRESTClientOperation,
+)
+from mcp_server.core.setting import Setting
+from mcp_server.server import GravitinoMCPServer, _parse_mcp_url
+from tests.unit.tools import MockOperation
+
+
+class TestParseMcpUrl(unittest.TestCase):
+ """_parse_mcp_url accepts http and https and rejects other schemes."""
+
+ def test_http_url(self):
+ self.assertEqual(
+ _parse_mcp_url("http://127.0.0.1:8000/mcp"),
+ ("127.0.0.1", 8000, "/mcp"),
+ )
+
+ def test_https_url(self):
+ self.assertEqual(
+ _parse_mcp_url("https://mcphost:9443/mcp"),
+ ("mcphost", 9443, "/mcp"),
+ )
+
+ def test_https_default_port(self):
+ _, port, _ = _parse_mcp_url("https://mcphost/mcp")
+ self.assertEqual(port, 443)
+
+ def test_http_default_port(self):
+ _, port, _ = _parse_mcp_url("http://mcphost/mcp")
+ self.assertEqual(port, 80)
+
+ def test_unsupported_scheme_rejected(self):
+ with self.assertRaises(ValueError):
+ _parse_mcp_url("ftp://mcphost/mcp")
+
+
+class TestRunHttpTransport(unittest.TestCase):
+ """GravitinoMCPServer.run() wires transport name and TLS config
correctly."""
+
+ def setUp(self):
+ RESTClientFactory.set_rest_client(MockOperation)
+
+ def tearDown(self):
+ # Restore the default so this global mutation can't leak into other
tests.
+ RESTClientFactory.set_rest_client(PlainRESTClientOperation)
+
+ def _run_and_capture(self, setting: Setting) -> dict:
+ """Run the server with run_async patched; return the kwargs it was
called with."""
+ server = GravitinoMCPServer(setting)
+ captured = {}
+
+ async def fake_run_async(**kwargs):
+ captured.update(kwargs)
+
+ with patch.object(server.mcp, "run_async", side_effect=fake_run_async):
+ server.run()
+ return captured
+
+ def test_http_transport(self):
+ setting = Setting(
+ metalake="ml",
+ transport="http",
+ mcp_url="http://127.0.0.1:8000/mcp",
+ )
+ kwargs = self._run_and_capture(setting)
+ self.assertEqual(kwargs["transport"], "http")
+ self.assertEqual(kwargs["host"], "127.0.0.1")
+ self.assertEqual(kwargs["port"], 8000)
+ self.assertEqual(kwargs["path"], "/mcp")
+ self.assertNotIn("uvicorn_config", kwargs)
+
+ def test_streamable_http_alias(self):
+ setting = Setting(
+ metalake="ml",
+ transport="streamable-http",
+ mcp_url="http://127.0.0.1:8000/mcp",
+ )
+ kwargs = self._run_and_capture(setting)
+ self.assertEqual(kwargs["transport"], "streamable-http")
+
+ def test_tls_config_wired_when_cert_and_key_set(self):
+ setting = Setting(
+ metalake="ml",
+ transport="streamable-http",
+ mcp_url="https://127.0.0.1:8443/mcp",
+ tls_cert="/path/to/cert.pem",
+ tls_key="/path/to/key.pem",
+ )
+ kwargs = self._run_and_capture(setting)
+ self.assertEqual(
+ kwargs["uvicorn_config"],
+ {
+ "ssl_certfile": "/path/to/cert.pem",
+ "ssl_keyfile": "/path/to/key.pem",
+ },
+ )
+
+ def test_lone_cert_rejected(self):
+ """TLS requires both cert and key; a lone cert is rejected."""
+ setting = Setting(
+ metalake="ml",
+ transport="http",
+ mcp_url="https://127.0.0.1:8443/mcp",
+ tls_cert="/path/to/cert.pem",
+ tls_key="",
+ )
+ with self.assertRaises(ValueError):
+ self._run_and_capture(setting)
+
+ def test_https_url_without_tls_rejected(self):
+ """An https URL without cert/key must not silently serve plain HTTP."""
+ setting = Setting(
+ metalake="ml",
+ transport="http",
+ mcp_url="https://127.0.0.1:8443/mcp",
+ )
+ with self.assertRaises(ValueError):
+ self._run_and_capture(setting)
+
+ def test_http_url_with_tls_rejected(self):
+ """TLS configured behind an http URL is a misconfiguration and
rejected."""
+ setting = Setting(
+ metalake="ml",
+ transport="http",
+ mcp_url="http://127.0.0.1:8000/mcp",
+ tls_cert="/path/to/cert.pem",
+ tls_key="/path/to/key.pem",
+ )
+ with self.assertRaises(ValueError):
+ self._run_and_capture(setting)
diff --git a/mcp-server/tests/unit/tools/mock_operation.py
b/mcp-server/tests/unit/tools/mock_operation.py
index d76ba94b60..e1fc540442 100644
--- a/mcp-server/tests/unit/tools/mock_operation.py
+++ b/mcp-server/tests/unit/tools/mock_operation.py
@@ -31,7 +31,7 @@ from mcp_server.client.statistic_operation import
StatisticOperation
class MockOperation(GravitinoOperation):
- def __init__(self, metalake, uri):
+ def __init__(self, metalake, uri, authorization=""):
pass
def as_table_operation(self) -> TableOperation: