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)

Reply via email to