This is an automated email from the ASF dual-hosted git repository.
aminghadersohi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 8d026b4a568 fix(mcp): skip dashboard live updates when websockets are
disabled or realtime access is missing (#44796)
8d026b4a568 is described below
commit 8d026b4a5682fa3c5fc24f75f1ac4271c35f424d
Author: Amin Ghadersohi <[email protected]>
AuthorDate: Sat Oct 3 11:48:02 2026 +1000
fix(mcp): skip dashboard live updates when websockets are disabled or
realtime access is missing (#44796)
---
docs/admin_docs/configuration/mcp-server.mdx | 12 +++--
.../dashboard/tool/apply_dashboard_filters.py | 8 ++++
.../dashboard/tool/test_apply_dashboard_filters.py | 54 +++++++++++++++++++++-
3 files changed, 67 insertions(+), 7 deletions(-)
diff --git a/docs/admin_docs/configuration/mcp-server.mdx
b/docs/admin_docs/configuration/mcp-server.mdx
index 84119429973..1fd25f06a45 100644
--- a/docs/admin_docs/configuration/mcp-server.mdx
+++ b/docs/admin_docs/configuration/mcp-server.mdx
@@ -165,11 +165,13 @@ The MCP process and websocket transport must use the same
Framework feature flag.
The response's `live_update_pushed` field reports publication success, not
-browser receipt or application. Without a backend, an identifiable principal,
-or a successful publish, it is `false`; the created `dashboard_url` remains
-the fallback. An open dashboard resolves matching notifications through the
-existing dashboard permalink API and applies the returned filters without a
-reload. A “Filters applied from chat” toast offers **Undo**, restoring the
+browser receipt or application. When `WEBSOCKET_ENABLE` is disabled or unset,
+no notification is published and this field is `false`. Without a backend,
+`can_read` on `Realtime` for the calling principal, an identifiable principal,
+or a successful publish, it is also `false`; the created
+`dashboard_url` remains the fallback. An open dashboard resolves matching
+notifications through the existing dashboard permalink API and applies the
+returned filters without a reload. A “Filters applied from chat” toast offers
**Undo**, restoring the
affected filters' previous selections. The toast carries an action, so it has
no auto-dismiss timer and stays up until Undo or its close button is used,
which keeps the action reachable for keyboard and assistive-technology users.
diff --git a/superset/mcp_service/dashboard/tool/apply_dashboard_filters.py
b/superset/mcp_service/dashboard/tool/apply_dashboard_filters.py
index 25b6b32d5a8..64a53414afd 100644
--- a/superset/mcp_service/dashboard/tool/apply_dashboard_filters.py
+++ b/superset/mcp_service/dashboard/tool/apply_dashboard_filters.py
@@ -29,6 +29,7 @@ import logging
from typing import Any
from fastmcp import Context
+from flask import current_app
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.constants import EMPTY_FILTER_SQL_EXPRESSION, NO_TIME_RANGE
@@ -71,10 +72,17 @@ def _publish_filters_applied(dashboard_id: int,
permalink_key: str) -> bool:
from superset.coordination.base import CoordinationService
from superset.realtime.publish import publish_realtime
from superset.websocket.channel import get_realtime_principal
+ from superset.websocket.permissions import
can_access_realtime_notifications
try:
+ if not current_app.config.get("WEBSOCKET_ENABLE"):
+ return False
if not CoordinationService.is_backend_defined():
return False
+ # Same gate the websocket channel cookie is minted behind: without it
the
+ # caller has no authorized socket to receive the nudge.
+ if not can_access_realtime_notifications():
+ return False
principal = get_realtime_principal()
if principal is None:
return False
diff --git
a/tests/unit_tests/mcp_service/dashboard/tool/test_apply_dashboard_filters.py
b/tests/unit_tests/mcp_service/dashboard/tool/test_apply_dashboard_filters.py
index 818e6b7e0d3..7f500f38868 100644
---
a/tests/unit_tests/mcp_service/dashboard/tool/test_apply_dashboard_filters.py
+++
b/tests/unit_tests/mcp_service/dashboard/tool/test_apply_dashboard_filters.py
@@ -39,6 +39,7 @@ from unittest.mock import Mock, patch
import pytest
from fastmcp import Client
+from flask import current_app
from superset.commands.dashboard.exceptions import (
DashboardAccessDeniedError,
@@ -761,6 +762,7 @@ async def
test_data_mask_round_trips_through_get_dashboard_layout(
@pytest.mark.asyncio
[email protected]("websocket_enabled", [None, False, True])
@pytest.mark.parametrize(
("backend_defined", "publish_fails", "expected"),
[(True, False, True), (False, False, False), (True, True, False)],
@@ -768,6 +770,7 @@ async def
test_data_mask_round_trips_through_get_dashboard_layout(
async def test_realtime_publish_outcome(
mcp_server: object,
mock_auth: Mock,
+ websocket_enabled: bool | None,
backend_defined: bool,
publish_fails: bool,
expected: bool,
@@ -776,6 +779,7 @@ async def test_realtime_publish_outcome(
mock_auth.return_value.id = 42
captured: dict[str, Any] = {}
with (
+ patch.dict(current_app.config, WEBSOCKET_ENABLE=websocket_enabled),
patch(DAO_GET, return_value=_mock_dashboard([SELECT_FILTER])),
patch(CREATE_PERMALINK, side_effect=_mock_permalink_command(captured)),
patch(
@@ -790,7 +794,10 @@ async def test_realtime_publish_outcome(
"superset.security_manager.get_current_guest_user_if_guest",
return_value=None,
),
+ patch("superset.security_manager.can_access", return_value=True),
):
+ if websocket_enabled is None:
+ current_app.config.pop("WEBSOCKET_ENABLE", None)
data = await _call(
mcp_server,
{
@@ -799,10 +806,10 @@ async def test_realtime_publish_outcome(
},
)
- assert data["live_update_pushed"] is expected
+ assert data["live_update_pushed"] is (expected and bool(websocket_enabled))
assert data["error"] is None
assert data["permalink_key"] == "permakey123"
- if backend_defined:
+ if backend_defined and websocket_enabled:
publish.assert_called_once()
assert json.loads(publish.call_args.args[1]) == {
"topic": "dashboard.filters_applied",
@@ -822,6 +829,7 @@ def test_realtime_guest_or_missing_principal(channel: str |
None) -> None:
)
with (
+ patch.dict(current_app.config, WEBSOCKET_ENABLE=True),
patch(
"superset.coordination.base.CoordinationService.is_backend_defined",
return_value=True,
@@ -837,6 +845,7 @@ def test_realtime_guest_or_missing_principal(channel: str |
None) -> None:
"superset.websocket.channel.get_current_guest_subscriber_key",
return_value=channel,
),
+ patch("superset.security_manager.can_access", return_value=True),
):
assert _publish_filters_applied(1, "key") is (channel is not None)
if channel is None:
@@ -850,6 +859,47 @@ def test_realtime_guest_or_missing_principal(channel: str
| None) -> None:
)
[email protected]("can_read_realtime", [True, False])
+def test_realtime_publish_requires_realtime_permission(
+ can_read_realtime: bool,
+) -> None:
+ """Publish only when the caller holds the permission its socket is gated
on."""
+ from superset.mcp_service.dashboard.tool.apply_dashboard_filters import (
+ _publish_filters_applied,
+ )
+
+ with (
+ patch.dict(current_app.config, WEBSOCKET_ENABLE=True),
+ patch(
+
"superset.coordination.base.CoordinationService.is_backend_defined",
+ return_value=True,
+ ),
+ patch(
+ "superset.realtime.publish.publish_realtime", return_value=True
+ ) as publish,
+ patch(
+ "superset.security_manager.get_current_guest_user_if_guest",
+ return_value=None,
+ ),
+ patch("superset.websocket.channel.get_user_id", return_value=42),
+ patch(
+ "superset.security_manager.can_access",
return_value=can_read_realtime
+ ) as can_access,
+ ):
+ assert _publish_filters_applied(1, "key") is can_read_realtime
+
+ can_access.assert_called_once_with("can_read", "Realtime")
+ if can_read_realtime:
+ publish.assert_called_once_with(
+ topic="dashboard.filters_applied",
+ scope="principal",
+ payload={"dashboard_id": 1, "permalink_key": "key"},
+ routes=["user:42"],
+ )
+ else:
+ publish.assert_not_called()
+
+
@pytest.mark.asyncio
@pytest.mark.parametrize("reference", ["1", "uuid", "slug"])
async def test_stack_filters_across_turns(mcp_server: object, reference: str)
-> None: