This is an automated email from the ASF dual-hosted git repository. yuqi1129 pushed a commit to branch feat/mcp-governance-task3-6 in repository https://gitbox.apache.org/repos/asf/gravitino.git
commit a821a009ea0f1246e788aba46a7857a168bb8ac1 Author: yuqi <[email protected]> AuthorDate: Wed Jun 10 20:36:40 2026 +0800 [#11572] feat(mcp-server): per-request token isolation for HTTP transport GravitinoContext.rest_client() now extracts the Bearer token from the current HTTP request's Authorization header via fastmcp.server.dependencies.get_http_request(). A per-request token creates a fresh httpx client carrying that token, ensuring concurrent multi-principal sessions are fully isolated — one principal's token never leaks into another's Gravitino calls. Falls back to the shared default client (startup token) in stdio mode or when no Authorization header is present. Also add _extract_bearer_token() and _get_request_token() as testable helpers. tests/unit/test_per_request_token.py: 11 tests covering token extraction, stdio fallback, per-request override, and concurrent session isolation. --- mcp-server/mcp_server/core/context.py | 46 ++++++- mcp-server/mcp_server/server.py | 7 +- mcp-server/tests/unit/test_per_request_token.py | 160 ++++++++++++++++++++++++ 3 files changed, 207 insertions(+), 6 deletions(-) diff --git a/mcp-server/mcp_server/core/context.py b/mcp-server/mcp_server/core/context.py index 83350a7d10..f2cde66692 100644 --- a/mcp-server/mcp_server/core/context.py +++ b/mcp-server/mcp_server/core/context.py @@ -19,11 +19,53 @@ from mcp_server.client.factory import RESTClientFactory from mcp_server.core.setting import Setting +def _extract_bearer_token(authorization: str) -> str: + """Parse a Bearer token from a raw Authorization header value.""" + parts = authorization.split() + if len(parts) == 2 and parts[0].lower() == "bearer": + return parts[1] + return "" + + +def _get_request_token() -> str: + """Extract the Bearer token from the current HTTP request, if any. + + Returns an empty string in stdio mode or when the header is absent. + """ + try: + from fastmcp.server.dependencies import get_http_request + + authorization = get_http_request().headers.get("authorization", "") + return _extract_bearer_token(authorization) + except Exception: # noqa: BLE001 – stdio mode or missing request context + return "" + + class GravitinoContext: def __init__(self, setting: Setting): - self.gravitino_client = RESTClientFactory.create_rest_client( + self._setting = setting + # Fallback client for stdio mode or when no per-request token is present. + self._default_client = RESTClientFactory.create_rest_client( setting.metalake, setting.gravitino_uri, setting.token ) def rest_client(self): - return self.gravitino_client + """Return a REST client carrying the correct identity for this request. + + In HTTP transport mode the Bearer token from the incoming MCP request's + Authorization header takes priority over the static startup token. + This ensures concurrent sessions with different principals are fully + isolated — one principal's token never leaks into another's Gravitino 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. + """ + request_token = _get_request_token() + if request_token: + return RESTClientFactory.create_rest_client( + self._setting.metalake, + self._setting.gravitino_uri, + request_token, + ) + return self._default_client diff --git a/mcp-server/mcp_server/server.py b/mcp-server/mcp_server/server.py index 8d66fec936..aea7df7f78 100644 --- a/mcp-server/mcp_server/server.py +++ b/mcp-server/mcp_server/server.py @@ -43,15 +43,14 @@ from mcp_server.tools import load_tools def _get_principal_from_request() -> str: - """Extract the principal from the current HTTP request's Authorization header. + """Derive a display principal from the current HTTP request's Authorization header. - Returns "anonymous" in stdio mode (no HTTP request) or when no token is present. + Returns "anonymous" in stdio mode or when no token is present. """ try: from fastmcp.server.dependencies import get_http_request - request = get_http_request() - authorization = request.headers.get("authorization", "") + authorization = get_http_request().headers.get("authorization", "") return audit._extract_principal(authorization) except Exception: # noqa: BLE001 – stdio mode or no request context return "anonymous" 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..c08cbeeb39 --- /dev/null +++ b/mcp-server/tests/unit/test_per_request_token.py @@ -0,0 +1,160 @@ +# 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 token isolation (Task 6). + +GravitinoContext.rest_client() must return a client carrying the token from +the current HTTP request, not the shared startup token, when an Authorization +header is present. This ensures concurrent multi-principal sessions are fully +isolated in HTTP transport mode. +""" + +import unittest +from unittest.mock import MagicMock, patch + +from mcp_server.core.context import ( + GravitinoContext, + _extract_bearer_token, + _get_request_token, +) +from mcp_server.core.setting import Setting + + +class TestExtractBearerToken(unittest.TestCase): + """Unit tests for the token extraction helper.""" + + def test_well_formed_bearer_header(self): + self.assertEqual(_extract_bearer_token("Bearer mytoken123"), "mytoken123") + + def test_case_insensitive_bearer(self): + self.assertEqual(_extract_bearer_token("bearer MYTOKEN"), "MYTOKEN") + + def test_empty_header_returns_empty(self): + self.assertEqual(_extract_bearer_token(""), "") + + def test_non_bearer_scheme_returns_empty(self): + self.assertEqual(_extract_bearer_token("Basic dXNlcjpwYXNz"), "") + + def test_only_scheme_no_token_returns_empty(self): + self.assertEqual(_extract_bearer_token("Bearer"), "") + + +class TestGetRequestToken(unittest.TestCase): + """Unit tests for _get_request_token() (HTTP context extraction).""" + + def test_returns_token_when_http_request_available(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, + ): + token = _get_request_token() + + self.assertEqual(token, "request-token-xyz") + + 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"), + ): + token = _get_request_token() + + self.assertEqual(token, "") + + 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, + ): + token = _get_request_token() + + self.assertEqual(token, "") + + +class TestGravitinoContextPerRequestToken(unittest.TestCase): + """GravitinoContext.rest_client() isolates per-request tokens.""" + + 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_token_overrides_startup_token(self): + """When an HTTP request carries a token, it takes priority over the startup token.""" + ctx = self._make_context(startup_token="startup-token") + + mock_request = MagicMock() + mock_request.headers.get.return_value = "Bearer request-token" + + 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"), "Bearer request-token") + + def test_falls_back_to_default_client_when_no_request_token(self): + """When no per-request token exists, 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 tokens must produce different client instances.""" + ctx = self._make_context() + + def make_mock(token: str) -> MagicMock: + m = MagicMock() + m.headers.get.return_value = f"Bearer {token}" + return m + + with patch( + "fastmcp.server.dependencies.get_http_request", + return_value=make_mock("alice-token"), + ): + client_alice = ctx.rest_client() + + with patch( + "fastmcp.server.dependencies.get_http_request", + return_value=make_mock("bob-token"), + ): + 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"), "Bearer alice-token") + self.assertEqual(bob_headers.get("authorization"), "Bearer bob-token") + self.assertIsNot(client_alice, client_bob)
