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:

Reply via email to