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 031702432fda09005f91ceb2f0ea45e2b4f7ed8c Author: yuqi <[email protected]> AuthorDate: Thu Jun 11 14:34:18 2026 +0800 [#11572] feat(mcp-server): accept streamable-http transport alias and serve endpoint over TLS Closes the remaining transport-surface gaps so the PRD demo command runs as-is: uv run mcp_server --metalake <ml> --gravitino-uri https://<host>:8090 \ --transport streamable-http --mcp-url https://<mcp-host>:<port> \ --tls-cert <cert.pem> --tls-key <key.pem> - main.py: accept `streamable-http` as a --transport choice (equivalent to `http`); add --tls-cert / --tls-key options. - setting.py: add tls_cert / tls_key fields. - server.py: _parse_mcp_url now accepts https:// (default port 443) and rejects non-http(s) schemes; _run_http maps the transport name and forwards ssl_certfile/ssl_keyfile to uvicorn via FastMCP's uvicorn_config when both cert and key are set. - tests/unit/test_transport_tls.py: 9 tests for URL parsing, the streamable-http alias, and TLS config wiring. Verified manually: server starts as `Uvicorn running on https://...`, an HTTPS initialize request returns 200, and plain HTTP to the TLS port is refused. --- mcp-server/mcp_server/core/setting.py | 7 +- mcp-server/mcp_server/main.py | 26 +++++- mcp-server/mcp_server/server.py | 38 +++++++-- mcp-server/tests/unit/test_transport_tls.py | 124 ++++++++++++++++++++++++++++ 4 files changed, 184 insertions(+), 11 deletions(-) diff --git a/mcp-server/mcp_server/core/setting.py b/mcp-server/mcp_server/core/setting.py index 4d159d6c90..65427a9507 100644 --- a/mcp-server/mcp_server/core/setting.py +++ b/mcp-server/mcp_server/core/setting.py @@ -36,11 +36,16 @@ class Setting: # Bearer token forwarded to Gravitino on every request. # Empty string means anonymous (no Authorization header sent). token: str = "" + # 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})" + 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 ca60d8930f..fa459dd316 100644 --- a/mcp-server/mcp_server/main.py +++ b/mcp-server/mcp_server/main.py @@ -32,6 +32,8 @@ def do_main(): 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) @@ -91,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})", ) @@ -101,7 +104,8 @@ 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( @@ -113,6 +117,22 @@ def _parse_args(): "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() return args diff --git a/mcp-server/mcp_server/server.py b/mcp-server/mcp_server/server.py index 4280932c9e..a6c1558948 100644 --- a/mcp-server/mcp_server/server.py +++ b/mcp-server/mcp_server/server.py @@ -119,14 +119,17 @@ def _create_gravitino_mcp(setting: Setting) -> FastMCP: def _parse_mcp_url(url: 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("/"): @@ -155,8 +158,29 @@ 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 - ) + # 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)) 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..ac1eab3249 --- /dev/null +++ b/mcp-server/tests/unit/test_transport_tls.py @@ -0,0 +1,124 @@ +# 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.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): + host, port, path = _parse_mcp_url("https://mcphost/mcp") + self.assertEqual(port, 443) + + def test_http_default_port(self): + host, port, path = _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 _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_no_tls_when_only_cert_set(self): + """TLS requires both cert and key; a lone cert does not enable it.""" + setting = Setting( + metalake="ml", + transport="http", + mcp_url="http://127.0.0.1:8000/mcp", + tls_cert="/path/to/cert.pem", + tls_key="", + ) + kwargs = self._run_and_capture(setting) + self.assertNotIn("uvicorn_config", kwargs)
