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)