You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ju...@apache.org on 2026/08/06 04:50:31 UTC

(superset) branch 6.2 updated: fix(mcp): make streamable-http session mode configurable via MCP_STATELESS_HTTP (#42814)

This is an automated email from the ASF dual-hosted git repository.

justinpark pushed a commit to branch 6.2
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/6.2 by this push:
     new 5687c29c81d fix(mcp): make streamable-http session mode configurable via MCP_STATELESS_HTTP (#42814)
5687c29c81d is described below

commit 5687c29c81d0afcf35c9c3f46e25b5d2ac49585f
Author: JUST.in DO IT <ju...@airbnb.com>
AuthorDate: Wed Aug 5 21:31:22 2026 -0700

    fix(mcp): make streamable-http session mode configurable via MCP_STATELESS_HTTP (#42814)
    
    Co-authored-by: Claude Sonnet 5 <no...@anthropic.com>
    (cherry picked from commit d594a4d1578d506597cc902490e1139794acef8a)
---
 superset/mcp_service/__main__.py                | 17 ++++-
 superset/mcp_service/mcp_config.py              | 19 ++++++
 superset/mcp_service/server.py                  | 21 +++++-
 tests/unit_tests/mcp_service/test_mcp_config.py | 21 ++++++
 tests/unit_tests/mcp_service/test_mcp_server.py | 90 +++++++++++++++++++++++++
 5 files changed, 164 insertions(+), 4 deletions(-)

diff --git a/superset/mcp_service/__main__.py b/superset/mcp_service/__main__.py
index 30759c6e805..5abe025f55c 100644
--- a/superset/mcp_service/__main__.py
+++ b/superset/mcp_service/__main__.py
@@ -155,7 +155,22 @@ def main() -> None:
         if transport == "streamable-http":
             host = os.environ.get("FASTMCP_HOST", "127.0.0.1")
             port = int(os.environ.get("FASTMCP_PORT", "5008"))
-            mcp.run(transport=transport, host=host, port=port, stateless_http=True)
+
+            # See MCP_STATELESS_HTTP's docstring in mcp_config.py -- stateless
+            # mode races a tool's progress notifications against the
+            # transport teardown that follows its HTTP request.
+            from superset.mcp_service.flask_singleton import get_flask_app
+            from superset.mcp_service.mcp_config import MCP_STATELESS_HTTP
+
+            stateless_http = get_flask_app().config.get(
+                "MCP_STATELESS_HTTP", MCP_STATELESS_HTTP
+            )
+            mcp.run(
+                transport=transport,
+                host=host,
+                port=port,
+                stateless_http=stateless_http,
+            )
         else:
             mcp.run(transport=transport)
 
diff --git a/superset/mcp_service/mcp_config.py b/superset/mcp_service/mcp_config.py
index 26705892428..e566104858b 100644
--- a/superset/mcp_service/mcp_config.py
+++ b/superset/mcp_service/mcp_config.py
@@ -45,6 +45,24 @@ MCP_SERVICE_PORT = 5008
 # MCP Debug mode - shows suppressed initialization output in stdio mode
 MCP_DEBUG = False
 
+# Streamable-HTTP session mode used by run_server() (superset/mcp_service/server.py)
+# and the CLI entrypoint (superset/mcp_service/__main__.py).
+#
+# True (default): each HTTP request gets a fresh, ephemeral transport that is
+# torn down as soon as that single request/response completes, while the
+# tool call it started keeps running as a background task. If a client gives
+# up on a still-running call (its own timeout, a reconnect, etc.), the next
+# progress notification that tool sends hits the now-closed transport and
+# raises anyio.ClosedResourceError/BrokenResourceError -- crashing that
+# session and disconnecting other concurrent clients on the same worker.
+#
+# False: sessions are tracked by Mcp-Session-Id and the transport stays alive
+# for the session's lifetime, so a client disconnecting mid-call no longer
+# crashes the tool. This requires session-affinity routing on Mcp-Session-Id
+# at the mesh/ingress layer for multi-pod deployments -- a client's follow-up
+# requests must land on the pod that created its session.
+MCP_STATELESS_HTTP = True
+
 # MCP RBAC - when True, tools with class_permission_name are checked
 # against the FAB security_manager before execution.
 MCP_RBAC_ENABLED = True
@@ -395,6 +413,7 @@ def get_mcp_config(app_config: Dict[str, Any] | None = None) -> Dict[str, Any]:
         "MCP_SERVICE_HOST": MCP_SERVICE_HOST,
         "MCP_SERVICE_PORT": MCP_SERVICE_PORT,
         "MCP_DEBUG": MCP_DEBUG,
+        "MCP_STATELESS_HTTP": MCP_STATELESS_HTTP,
         "MCP_RBAC_ENABLED": MCP_RBAC_ENABLED,
         "MCP_DISABLED_TOOLS": set(MCP_DISABLED_TOOLS),
         **MCP_SESSION_CONFIG,
diff --git a/superset/mcp_service/server.py b/superset/mcp_service/server.py
index 735f75c5df8..6cb76b518b9 100644
--- a/superset/mcp_service/server.py
+++ b/superset/mcp_service/server.py
@@ -33,6 +33,7 @@ from fastmcp.server.middleware import Middleware
 from superset.mcp_service.app import create_mcp_app, init_fastmcp_server
 from superset.mcp_service.mcp_config import (
     get_mcp_factory_config,
+    MCP_STATELESS_HTTP,
     MCP_STORE_CONFIG,
     MCP_TOOL_SEARCH_CONFIG,
 )
@@ -577,7 +578,11 @@ def run_server(
     Uses streamable-http transport for HTTP server mode.
 
     For multi-pod deployments, configure MCP_EVENT_STORE_CONFIG with Redis URL
-    to share session state across pods.
+    to share session state across pods. If MCP_STATELESS_HTTP is also set to
+    False (see its docstring in mcp_config.py), sessions are stateful and
+    multi-pod additionally requires session-affinity routing on
+    Mcp-Session-Id at the mesh/ingress layer -- otherwise a session's
+    follow-up requests can land on a pod that never created it.
 
     Args:
         host: Host to bind to
@@ -648,13 +653,23 @@ def run_server(
         try:
             logging.info("Starting FastMCP on %s:%s", host, port)
 
+            # See MCP_STATELESS_HTTP's docstring in mcp_config.py: stateless
+            # mode races a tool's progress notifications against the
+            # transport teardown that follows its HTTP request, crashing the
+            # session if a client disconnects mid-call.
+            stateless_http = (
+                flask_app.config.get("MCP_STATELESS_HTTP", MCP_STATELESS_HTTP)
+                if flask_app is not None
+                else MCP_STATELESS_HTTP
+            )
+
             if event_store is not None:
                 # Multi-pod: Use http_app with Redis EventStore, run with uvicorn
                 logging.info("Running in multi-pod mode with Redis EventStore")
                 app = mcp_instance.http_app(
                     transport="streamable-http",
                     event_store=event_store,
-                    stateless_http=True,
+                    stateless_http=stateless_http,
                 )
                 uvicorn.run(app, host=host, port=port)
             else:
@@ -664,7 +679,7 @@ def run_server(
                     transport="streamable-http",
                     host=host,
                     port=port,
-                    stateless_http=True,
+                    stateless_http=stateless_http,
                 )
         except Exception as e:
             logging.error("FastMCP failed: %s", e)
diff --git a/tests/unit_tests/mcp_service/test_mcp_config.py b/tests/unit_tests/mcp_service/test_mcp_config.py
index e626b05ffe8..83f6af81206 100644
--- a/tests/unit_tests/mcp_service/test_mcp_config.py
+++ b/tests/unit_tests/mcp_service/test_mcp_config.py
@@ -183,3 +183,24 @@ def test_get_mcp_config_respects_app_config_override() -> None:
     custom = {"execute_sql", "health_check"}
     config = get_mcp_config({"MCP_DISABLED_TOOLS": custom})
     assert config["MCP_DISABLED_TOOLS"] == custom
+
+
+def test_get_mcp_config_includes_mcp_stateless_http_key() -> None:
+    """get_mcp_config must include MCP_STATELESS_HTTP in its defaults dict, like
+    MCP_DEBUG and MCP_RBAC_ENABLED, so an operator override in superset_config.py
+    is actually read back out via flask_app.config (see run_server() and
+    __main__.main() in the mcp_service package, which read this key)."""
+    from superset.mcp_service.mcp_config import get_mcp_config, MCP_STATELESS_HTTP
+
+    config = get_mcp_config()
+    assert "MCP_STATELESS_HTTP" in config
+    assert config["MCP_STATELESS_HTTP"] is MCP_STATELESS_HTTP is True
+
+
+def test_get_mcp_config_respects_mcp_stateless_http_override() -> None:
+    """An operator's MCP_STATELESS_HTTP=False in superset_config.py must take
+    precedence over the module-level True default."""
+    from superset.mcp_service.mcp_config import get_mcp_config
+
+    config = get_mcp_config({"MCP_STATELESS_HTTP": False})
+    assert config["MCP_STATELESS_HTTP"] is False
diff --git a/tests/unit_tests/mcp_service/test_mcp_server.py b/tests/unit_tests/mcp_service/test_mcp_server.py
index e74313444ee..de842c01ca8 100644
--- a/tests/unit_tests/mcp_service/test_mcp_server.py
+++ b/tests/unit_tests/mcp_service/test_mcp_server.py
@@ -17,6 +17,10 @@
 
 """Tests for MCP server EventStore creation."""
 
+import contextlib
+import os
+from collections.abc import Iterator
+from typing import Any
 from unittest.mock import MagicMock, patch
 
 
@@ -158,3 +162,89 @@ def test_create_event_store_returns_none_when_redis_store_fails():
         result = create_event_store(config)
 
         assert result is None
+
+
+@contextlib.contextmanager
+def _run_server_dependencies(
+    flask_config: dict[str, Any],
+) -> Iterator[MagicMock]:
+    """Patch every ``run_server()`` collaborator except stateless_http resolution.
+
+    Returns the ``mcp_instance`` mock so callers can assert on the kwargs its
+    ``run()`` was called with -- everything else (auth, middleware, tool
+    search, event store) is stubbed out since this is only exercising the
+    ``flask_app.config.get("MCP_STATELESS_HTTP", ...)`` wiring, not those
+    other startup steps.
+    """
+    from superset.mcp_service import server
+
+    flask_app = MagicMock()
+    flask_app.config = flask_config
+    mcp_instance = MagicMock()
+
+    with (
+        patch.object(server, "configure_logging"),
+        patch.object(server, "_suppress_third_party_warnings"),
+        patch(
+            "superset.mcp_service.flask_singleton.get_flask_app",
+            return_value=flask_app,
+        ),
+        patch.object(server, "_create_auth_provider", return_value=None),
+        patch.object(server, "build_middleware_list", return_value=[]),
+        patch.object(
+            server, "create_response_size_guard_middleware", return_value=None
+        ),
+        patch(
+            "superset.mcp_service.caching.create_response_caching_middleware",
+            return_value=None,
+        ),
+        patch.object(server, "init_fastmcp_server", return_value=mcp_instance),
+        patch.object(server, "_apply_tool_search_transform"),
+        patch.object(server, "create_event_store", return_value=None),
+    ):
+        yield mcp_instance
+
+
+def test_run_server_defaults_stateless_http_to_true_when_unset() -> None:
+    """run_server() must fall back to MCP_STATELESS_HTTP's True default when the
+    operator's Flask config has no override.
+
+    This pins the production wiring added to fix mid-workflow disconnects: if
+    the ``flask_app.config.get("MCP_STATELESS_HTTP", MCP_STATELESS_HTTP)`` call
+    in ``run_server()`` were reverted to a hardcoded ``True``, or the default
+    were flipped, this test would still pass -- so it's the ``is True`` on the
+    *resolved* value, not just the module constant, that catches a broken
+    resolution.
+    """
+    from superset.mcp_service.server import run_server
+
+    port = 59901
+    os.environ.pop(f"FASTMCP_RUNNING_{port}", None)
+    try:
+        with _run_server_dependencies(flask_config={}) as mcp_instance:
+            run_server(host="127.0.0.1", port=port)
+
+        mcp_instance.run.assert_called_once()
+        assert mcp_instance.run.call_args.kwargs["stateless_http"] is True
+    finally:
+        os.environ.pop(f"FASTMCP_RUNNING_{port}", None)
+
+
+def test_run_server_respects_mcp_stateless_http_false_override() -> None:
+    """An operator's MCP_STATELESS_HTTP=False (the value deployments actually run,
+    per the docstring in mcp_config.py) must reach ``mcp_instance.run()`` rather
+    than the module's True default."""
+    from superset.mcp_service.server import run_server
+
+    port = 59902
+    os.environ.pop(f"FASTMCP_RUNNING_{port}", None)
+    try:
+        with _run_server_dependencies(
+            flask_config={"MCP_STATELESS_HTTP": False}
+        ) as mcp_instance:
+            run_server(host="127.0.0.1", port=port)
+
+        mcp_instance.run.assert_called_once()
+        assert mcp_instance.run.call_args.kwargs["stateless_http"] is False
+    finally:
+        os.environ.pop(f"FASTMCP_RUNNING_{port}", None)