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 4c3570b6c0ebae3effb0618ae8c9d1ecbeb25e07 Author: yuqi <[email protected]> AuthorDate: Wed Jun 10 20:11:48 2026 +0800 [#11565][#11566] feat(mcp-server): add --token CLI param and inject Bearer token into Gravitino REST calls - Setting: add `token` field; mask value in __str__ to avoid log leakage - main.py: add `--token` CLI arg (fallback to GRAVITINO_TOKEN env var) - PlainRESTClientOperation: accept token; set Authorization: Bearer header on httpx client when non-empty - RESTClientFactory.create_rest_client: pass token through - GravitinoContext: propagate setting.token to factory - MockOperation: accept token param (test compatibility) - tests/unit/test_auth_flow.py: 7 new tests covering token injection, masking, and context propagation --- mcp-server/mcp_server/client/factory.py | 5 +- .../client/plain/plain_rest_client_operation.py | 7 +- mcp-server/mcp_server/core/context.py | 2 +- mcp-server/mcp_server/core/setting.py | 11 +++ mcp-server/mcp_server/main.py | 11 +++ mcp-server/tests/unit/test_auth_flow.py | 93 ++++++++++++++++++++++ mcp-server/tests/unit/tools/mock_operation.py | 2 +- 7 files changed, 125 insertions(+), 6 deletions(-) diff --git a/mcp-server/mcp_server/client/factory.py b/mcp-server/mcp_server/client/factory.py index d990284d28..2e5489d053 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, token: str = "" ) -> "PlainRESTClientOperation": """ Create a new rest client instance with the specified parameters. @@ -37,11 +37,12 @@ class RESTClientFactory: Args: metalake_name: Name of the metalake uri: URI of the Gravitino server endpoint + token: Bearer token for Authorization header (empty = 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, token) @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..8bb8b51aa3 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,11 @@ 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, token: str = ""): + headers = {} + if token: + headers["Authorization"] = f"Bearer {token}" + _rest_client = httpx.AsyncClient(base_url=uri, headers=headers) self._catalog_operation = PlainRESTClientCatalogOperation( metalake_name, _rest_client ) diff --git a/mcp-server/mcp_server/core/context.py b/mcp-server/mcp_server/core/context.py index 6e540a0b51..83350a7d10 100644 --- a/mcp-server/mcp_server/core/context.py +++ b/mcp-server/mcp_server/core/context.py @@ -22,7 +22,7 @@ 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 + setting.metalake, setting.gravitino_uri, setting.token ) def rest_client(self): diff --git a/mcp-server/mcp_server/core/setting.py b/mcp-server/mcp_server/core/setting.py index 474fdbc6b4..4d159d6c90 100644 --- a/mcp-server/mcp_server/core/setting.py +++ b/mcp-server/mcp_server/core/setting.py @@ -33,3 +33,14 @@ class Setting: tags: Set[str] = field(default_factory=set) transport: str = DefaultSetting.default_transport mcp_url: str = DefaultSetting.default_mcp_url + # Bearer token forwarded to Gravitino on every request. + # Empty string means anonymous (no Authorization header sent). + token: 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})" + ) diff --git a/mcp-server/mcp_server/main.py b/mcp-server/mcp_server/main.py index 73a5aa265f..b1200b3180 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,7 @@ def do_main(): tags=args.include_tool_tags, transport=args.transport, mcp_url=args.mcp_url, + token=args.token, ) _init_logging(setting) logging.info("Gravitino MCP server setting: %s", setting) @@ -96,6 +98,15 @@ def _parse_args(): help=f"The url of MCP server if using http transport. (default: {DefaultSetting.default_mcp_url})", ) + parser.add_argument( + "--token", + type=str, + default=os.environ.get("GRAVITINO_TOKEN", ""), + help="Bearer token forwarded as Authorization header to Gravitino on every request. " + "Can also be set via the GRAVITINO_TOKEN environment variable. " + "When omitted, requests are sent without authentication.", + ) + args = parser.parse_args() return args 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..a413515bf9 --- /dev/null +++ b/mcp-server/tests/unit/test_auth_flow.py @@ -0,0 +1,93 @@ +# 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 unittest + +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 + + +class TestTokenInjection(unittest.TestCase): + """Verify Bearer token is correctly injected into the httpx client.""" + + def test_token_sets_authorization_header(self): + """When a token is provided, the httpx client carries Authorization: Bearer.""" + client = PlainRESTClientOperation( + "my_metalake", "http://localhost:8090", token="my-secret-token" + ) + headers = dict(client._catalog_operation.rest_client.headers) + self.assertEqual(headers.get("authorization"), "Bearer my-secret-token") + + def test_empty_token_no_authorization_header(self): + """When token is empty, no Authorization header is added.""" + client = PlainRESTClientOperation( + "my_metalake", "http://localhost:8090", token="" + ) + headers = dict(client._catalog_operation.rest_client.headers) + self.assertNotIn("authorization", headers) + + def test_no_token_argument_no_authorization_header(self): + """When token argument is omitted entirely, no Authorization header is added.""" + client = PlainRESTClientOperation("my_metalake", "http://localhost:8090") + headers = dict(client._catalog_operation.rest_client.headers) + self.assertNotIn("authorization", headers) + + +class TestSettingTokenMasking(unittest.TestCase): + """Verify that the token is not exposed in Setting string representation.""" + + 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_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 TestGravitinoContextTokenPropagation(unittest.TestCase): + """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() + headers = dict(rest_client._catalog_operation.rest_client.headers) + self.assertEqual(headers.get("authorization"), "Bearer ctx-token-xyz") + + 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() + headers = dict(rest_client._catalog_operation.rest_client.headers) + self.assertNotIn("authorization", headers) diff --git a/mcp-server/tests/unit/tools/mock_operation.py b/mcp-server/tests/unit/tools/mock_operation.py index d76ba94b60..a985bf7912 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, token=""): pass def as_table_operation(self) -> TableOperation:
