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

arivero 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 f7218e7a192 feat(mcp): expose current user identity in 
get_instance_info and add created_by_fk filter (#37967)
f7218e7a192 is described below

commit f7218e7a1929b3c1e86d74ec264736f360298aee
Author: Amin Ghadersohi <[email protected]>
AuthorDate: Tue Feb 17 07:11:34 2026 -0500

    feat(mcp): expose current user identity in get_instance_info and add 
created_by_fk filter (#37967)
---
 superset/mcp_service/app.py                        |  16 +-
 superset/mcp_service/chart/schemas.py              |   4 +-
 superset/mcp_service/dashboard/schemas.py          |  10 +-
 superset/mcp_service/system/schemas.py             |   7 +
 .../mcp_service/system/tool/get_instance_info.py   |  18 +-
 superset/mcp_service/utils/schema_utils.py         |   6 +-
 .../system/tool/test_get_current_user.py           | 363 +++++++++++++++++++++
 .../mcp_service/system/tool/test_get_schema.py     |   8 +-
 .../mcp_service/utils/test_schema_utils.py         |  11 +
 9 files changed, 433 insertions(+), 10 deletions(-)

diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py
index 9624964bc72..f43df61e487 100644
--- a/superset/mcp_service/app.py
+++ b/superset/mcp_service/app.py
@@ -76,7 +76,7 @@ Schema Discovery:
 - get_schema: Get schema metadata for chart/dataset/dashboard (columns, 
filters)
 
 System Information:
-- get_instance_info: Get instance-wide statistics and metadata
+- get_instance_info: Get instance-wide statistics, metadata, and current user 
identity
 - health_check: Simple health check tool (takes NO parameters, call without 
arguments)
 
 Available Resources:
@@ -95,6 +95,13 @@ To create a chart:
 3. generate_explore_link(dataset_id, config) -> preview interactively
 4. generate_chart(dataset_id, config, save_chart=True) -> save permanently
 
+To find your own charts/dashboards:
+1. get_instance_info -> get current_user.id
+2. list_charts(filters=[{{"col": "created_by_fk",
+   "opr": "eq", "value": current_user.id}}])
+3. Or: list_dashboards(filters=[{{"col": "created_by_fk",
+   "opr": "eq", "value": current_user.id}}])
+
 To explore data with SQL:
 1. get_instance_info -> find database_id
 2. execute_sql(database_id, sql) -> run query
@@ -127,6 +134,10 @@ Query Examples:
 - List time series charts:
   filters=[{{"col": "viz_type", "opr": "sw", "value": "echarts_timeseries"}}]
 - Search by name: search="sales"
+- My charts (use current_user.id from get_instance_info):
+  filters=[{{"col": "created_by_fk", "opr": "eq", "value": <user_id>}}]
+- My dashboards:
+  filters=[{{"col": "created_by_fk", "opr": "eq", "value": <user_id>}}]
 
 General usage tips:
 - All listing tools use 1-based pagination (first page is 1)
@@ -143,6 +154,9 @@ Input format:
 
 If you are unsure which tool to use, start with get_instance_info
 or use the quickstart prompt for an interactive guide.
+
+When you first connect, call get_instance_info to learn the user's identity.
+Greet them by their first name (from current_user) and offer to help.
 """
 
 
diff --git a/superset/mcp_service/chart/schemas.py 
b/superset/mcp_service/chart/schemas.py
index d02f3c7de02..a27c3e3ceef 100644
--- a/superset/mcp_service/chart/schemas.py
+++ b/superset/mcp_service/chart/schemas.py
@@ -270,10 +270,12 @@ class ChartFilter(ColumnOperator):
         "slice_name",
         "viz_type",
         "datasource_name",
+        "created_by_fk",
     ] = Field(
         ...,
         description="Column to filter on. Use get_schema(model_type='chart') 
for "
-        "available filter columns.",
+        "available filter columns. Use created_by_fk with the user ID from "
+        "get_instance_info's current_user to find charts created by a specific 
user.",
     )
     opr: ColumnOperatorEnum = Field(
         ...,
diff --git a/superset/mcp_service/dashboard/schemas.py 
b/superset/mcp_service/dashboard/schemas.py
index d9dec129548..54c4e724471 100644
--- a/superset/mcp_service/dashboard/schemas.py
+++ b/superset/mcp_service/dashboard/schemas.py
@@ -163,10 +163,16 @@ class DashboardFilter(ColumnOperator):
         "dashboard_title",
         "published",
         "favorite",
+        "created_by_fk",
     ] = Field(
         ...,
-        description="Column to filter on. Use 
get_schema(model_type='dashboard') for "
-        "available filter columns.",
+        description=(
+            "Column to filter on. Use "
+            "get_schema(model_type='dashboard') for available "
+            "filter columns. Use created_by_fk with the user "
+            "ID from get_instance_info's current_user to find "
+            "dashboards created by a specific user."
+        ),
     )
     opr: ColumnOperatorEnum = Field(
         ...,
diff --git a/superset/mcp_service/system/schemas.py 
b/superset/mcp_service/system/schemas.py
index 5d4a8ab7db0..93b676a0e1a 100644
--- a/superset/mcp_service/system/schemas.py
+++ b/superset/mcp_service/system/schemas.py
@@ -22,6 +22,8 @@ This module contains Pydantic models for serializing Superset 
instance metadata
 system-level info.
 """
 
+from __future__ import annotations
+
 from datetime import datetime
 from typing import Dict, List
 
@@ -122,6 +124,11 @@ class InstanceInfo(BaseModel):
     popular_content: PopularContent = Field(
         ..., description="Popular content information"
     )
+    current_user: UserInfo | None = Field(
+        None,
+        description="The authenticated user making the request. "
+        "Use current_user.id with created_by_fk filter to find your own 
assets.",
+    )
     timestamp: datetime = Field(..., description="Response timestamp")
 
 
diff --git a/superset/mcp_service/system/tool/get_instance_info.py 
b/superset/mcp_service/system/tool/get_instance_info.py
index 7c142acabfe..8d383ae167f 100644
--- a/superset/mcp_service/system/tool/get_instance_info.py
+++ b/superset/mcp_service/system/tool/get_instance_info.py
@@ -30,6 +30,7 @@ from superset.mcp_service.mcp_core import InstanceInfoCore
 from superset.mcp_service.system.schemas import (
     GetSupersetInstanceInfoRequest,
     InstanceInfo,
+    UserInfo,
 )
 from superset.mcp_service.system.system_utils import (
     calculate_dashboard_breakdown,
@@ -81,6 +82,8 @@ def get_instance_info(
     """
     try:
         # Import DAOs at runtime to avoid circular imports
+        from flask import g
+
         from superset.daos.chart import ChartDAO
         from superset.daos.dashboard import DashboardDAO
         from superset.daos.database import DatabaseDAO
@@ -100,7 +103,20 @@ def get_instance_info(
 
         # Run the configurable core
         with event_logger.log_context(action="mcp.get_instance_info.metrics"):
-            return _instance_info_core.run_tool()
+            result = _instance_info_core.run_tool()
+
+        # Attach the authenticated user's identity to the response
+        user = getattr(g, "user", None)
+        if user is not None:
+            result.current_user = UserInfo(
+                id=getattr(user, "id", None),
+                username=getattr(user, "username", None),
+                first_name=getattr(user, "first_name", None),
+                last_name=getattr(user, "last_name", None),
+                email=getattr(user, "email", None),
+            )
+
+        return result
 
     except Exception as e:
         error_msg = f"Unexpected error in instance info: {str(e)}"
diff --git a/superset/mcp_service/utils/schema_utils.py 
b/superset/mcp_service/utils/schema_utils.py
index 3c0a617f4fc..62992b8594d 100644
--- a/superset/mcp_service/utils/schema_utils.py
+++ b/superset/mcp_service/utils/schema_utils.py
@@ -446,10 +446,8 @@ def parse_request(
     def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
         import types
 
-        parse_enabled = _is_parse_request_enabled()
-
         def _maybe_parse(request: Any) -> Any:
-            if parse_enabled:
+            if _is_parse_request_enabled():
                 return parse_json_or_model(request, request_class, "request")
             return request
 
@@ -501,7 +499,7 @@ def parse_request(
         # Copy docstring from original function (not wrapper, which has no 
docstring)
         new_wrapper.__doc__ = func.__doc__
 
-        request_annotation = str | request_class if parse_enabled else 
request_class
+        request_annotation = str | request_class
         _apply_signature_for_fastmcp(new_wrapper, func, request_annotation)
 
         return new_wrapper
diff --git a/tests/unit_tests/mcp_service/system/tool/test_get_current_user.py 
b/tests/unit_tests/mcp_service/system/tool/test_get_current_user.py
new file mode 100644
index 00000000000..91d8fbc6e8b
--- /dev/null
+++ b/tests/unit_tests/mcp_service/system/tool/test_get_current_user.py
@@ -0,0 +1,363 @@
+# 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 current_user in get_instance_info and created_by_fk filtering."""
+
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+from pydantic import ValidationError
+
+from superset.mcp_service.app import mcp
+from superset.mcp_service.chart.schemas import ChartFilter
+from superset.mcp_service.dashboard.schemas import DashboardFilter
+from superset.mcp_service.system.schemas import InstanceInfo, UserInfo
+from superset.utils import json
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
[email protected]
+def mcp_server():
+    return mcp
+
+
[email protected](autouse=True)
+def mock_auth():
+    """Mock authentication for all tests."""
+    with patch("superset.mcp_service.auth.get_user_from_request") as 
mock_get_user:
+        mock_user = Mock()
+        mock_user.id = 1
+        mock_user.username = "admin"
+        mock_get_user.return_value = mock_user
+        yield mock_get_user
+
+
+# ---------------------------------------------------------------------------
+# Helper to build a minimal InstanceInfo
+# ---------------------------------------------------------------------------
+def _make_instance_info(**kwargs):
+    """Build a minimal InstanceInfo with defaults; override with kwargs."""
+    from datetime import datetime, timezone
+
+    from superset.mcp_service.system.schemas import (
+        DashboardBreakdown,
+        DatabaseBreakdown,
+        InstanceSummary,
+        PopularContent,
+        RecentActivity,
+    )
+
+    defaults = {
+        "instance_summary": InstanceSummary(
+            total_dashboards=0,
+            total_charts=0,
+            total_datasets=0,
+            total_databases=0,
+            total_users=0,
+            total_roles=0,
+            total_tags=0,
+            avg_charts_per_dashboard=0.0,
+        ),
+        "recent_activity": RecentActivity(
+            dashboards_created_last_30_days=0,
+            charts_created_last_30_days=0,
+            datasets_created_last_30_days=0,
+            dashboards_modified_last_7_days=0,
+            charts_modified_last_7_days=0,
+            datasets_modified_last_7_days=0,
+        ),
+        "dashboard_breakdown": DashboardBreakdown(
+            published=0,
+            unpublished=0,
+            certified=0,
+            with_charts=0,
+            without_charts=0,
+        ),
+        "database_breakdown": DatabaseBreakdown(by_type={}),
+        "popular_content": PopularContent(top_tags=[], top_creators=[]),
+        "timestamp": datetime.now(timezone.utc),
+    }
+    defaults.update(kwargs)
+    return InstanceInfo(**defaults)
+
+
+# ---------------------------------------------------------------------------
+# Schema-level tests: UserInfo
+# ---------------------------------------------------------------------------
+
+
+def test_user_info_all_fields():
+    """Test UserInfo with all fields populated."""
+    user = UserInfo(
+        id=42,
+        username="sophie",
+        first_name="Sophie",
+        last_name="Test",
+        email="[email protected]",
+    )
+    assert user.id == 42
+    assert user.username == "sophie"
+    assert user.first_name == "Sophie"
+    assert user.last_name == "Test"
+    assert user.email == "[email protected]"
+
+
+def test_user_info_with_minimal_fields():
+    """Test UserInfo with only required fields (all optional)."""
+    user = UserInfo(id=1, username="admin")
+    assert user.id == 1
+    assert user.username == "admin"
+    assert user.first_name is None
+    assert user.last_name is None
+    assert user.email is None
+
+
+def test_user_info_serialization_roundtrip():
+    """Test UserInfo can be serialized to dict and back."""
+    user = UserInfo(id=7, username="testuser", first_name="Test", 
email="[email protected]")
+    data = user.model_dump()
+    assert data["id"] == 7
+    assert data["username"] == "testuser"
+    assert data["first_name"] == "Test"
+    assert data["last_name"] is None
+    assert data["email"] == "[email protected]"
+
+    # Reconstruct
+    user2 = UserInfo(**data)
+    assert user2 == user
+
+
+# ---------------------------------------------------------------------------
+# Schema-level tests: InstanceInfo.current_user
+# ---------------------------------------------------------------------------
+
+
+def test_instance_info_current_user_default_none():
+    """Test that InstanceInfo.current_user defaults to None."""
+    info = _make_instance_info()
+    assert info.current_user is None
+
+
+def test_instance_info_with_current_user():
+    """Test that InstanceInfo accepts a current_user UserInfo."""
+    user = UserInfo(
+        id=42,
+        username="sophie",
+        first_name="Sophie",
+        last_name="Test",
+        email="[email protected]",
+    )
+    info = _make_instance_info(current_user=user)
+    assert info.current_user is not None
+    assert info.current_user.id == 42
+    assert info.current_user.username == "sophie"
+    assert info.current_user.first_name == "Sophie"
+    assert info.current_user.last_name == "Test"
+    assert info.current_user.email == "[email protected]"
+
+
+def test_instance_info_current_user_in_serialized_output():
+    """Test current_user appears when InstanceInfo is serialized to JSON."""
+    user = UserInfo(id=1, username="admin", first_name="Admin")
+    info = _make_instance_info(current_user=user)
+    data = json.loads(info.model_dump_json())
+    assert "current_user" in data
+    assert data["current_user"]["id"] == 1
+    assert data["current_user"]["username"] == "admin"
+    assert data["current_user"]["first_name"] == "Admin"
+
+
+def test_instance_info_none_current_user_in_serialized_output():
+    """Test current_user is null when not set in serialized output."""
+    info = _make_instance_info()
+    data = json.loads(info.model_dump_json())
+    assert "current_user" in data
+    assert data["current_user"] is None
+
+
+# ---------------------------------------------------------------------------
+# Tool-level tests: get_instance_info via MCP Client
+# ---------------------------------------------------------------------------
+
+
+class TestGetInstanceInfoCurrentUserViaMCP:
+    """Test get_instance_info tool returns current_user via MCP client."""
+
+    @pytest.mark.asyncio
+    async def test_get_instance_info_returns_current_user(self, mcp_server):
+        """Test that get_instance_info populates current_user from g.user."""
+        # Patch run_tool on the CLASS so all instances (including the
+        # module-level _instance_info_core) use the mock.  We avoid patching
+        # via dotted module path because __init__.py re-exports
+        # get_instance_info as a function, which shadows the submodule name
+        # and breaks mock resolution on Python 3.10.
+        from superset.mcp_service.mcp_core import InstanceInfoCore
+
+        mock_g_user = Mock()
+        mock_g_user.id = 5
+        mock_g_user.username = "sophie"
+        mock_g_user.first_name = "Sophie"
+        mock_g_user.last_name = "Beaumont"
+        mock_g_user.email = "[email protected]"
+
+        with (
+            patch.object(
+                InstanceInfoCore,
+                "run_tool",
+                return_value=_make_instance_info(),
+            ),
+            patch("flask.g") as mock_g,
+        ):
+            mock_g.user = mock_g_user
+
+            async with Client(mcp_server) as client:
+                result = await client.call_tool("get_instance_info", 
{"request": {}})
+
+        data = json.loads(result.content[0].text)
+        assert "current_user" in data
+        cu = data["current_user"]
+        assert cu["id"] == 5
+        assert cu["username"] == "sophie"
+        assert cu["first_name"] == "Sophie"
+        assert cu["last_name"] == "Beaumont"
+        assert cu["email"] == "[email protected]"
+
+    @pytest.mark.asyncio
+    async def test_get_instance_info_no_user_returns_null(self, mcp_server):
+        """Test that current_user is null when g.user is not set."""
+        from superset.mcp_service.mcp_core import InstanceInfoCore
+
+        with (
+            patch.object(
+                InstanceInfoCore,
+                "run_tool",
+                return_value=_make_instance_info(),
+            ),
+            patch("flask.g") as mock_g,
+        ):
+            # Simulate no user on g so getattr(g, "user", None) returns None
+            mock_g.user = None
+
+            async with Client(mcp_server) as client:
+                result = await client.call_tool("get_instance_info", 
{"request": {}})
+
+        data = json.loads(result.content[0].text)
+        assert data["current_user"] is None
+
+    @pytest.mark.asyncio
+    async def test_get_instance_info_user_missing_optional_attrs(self, 
mcp_server):
+        """Test current_user when g.user is missing optional attributes."""
+        from superset.mcp_service.mcp_core import InstanceInfoCore
+
+        # User object with only id and username (no first_name, etc.)
+        mock_g_user = Mock(spec=["id", "username"])
+        mock_g_user.id = 99
+        mock_g_user.username = "bot"
+
+        with (
+            patch.object(
+                InstanceInfoCore,
+                "run_tool",
+                return_value=_make_instance_info(),
+            ),
+            patch("flask.g") as mock_g,
+        ):
+            mock_g.user = mock_g_user
+
+            async with Client(mcp_server) as client:
+                result = await client.call_tool("get_instance_info", 
{"request": {}})
+
+        data = json.loads(result.content[0].text)
+        cu = data["current_user"]
+        assert cu["id"] == 99
+        assert cu["username"] == "bot"
+        # Missing attrs should be None via getattr default
+        assert cu["first_name"] is None
+        assert cu["last_name"] is None
+        assert cu["email"] is None
+
+
+# ---------------------------------------------------------------------------
+# Filter schema tests: created_by_fk
+# ---------------------------------------------------------------------------
+
+
+def test_chart_filter_accepts_created_by_fk():
+    """Test that ChartFilter accepts created_by_fk as a valid column."""
+    f = ChartFilter(col="created_by_fk", opr="eq", value=42)
+    assert f.col == "created_by_fk"
+    assert f.opr == "eq"
+    assert f.value == 42
+
+
+def test_chart_filter_created_by_fk_with_ne_operator():
+    """Test created_by_fk with 'ne' (not equal) operator."""
+    f = ChartFilter(col="created_by_fk", opr="ne", value=1)
+    assert f.col == "created_by_fk"
+    assert f.opr == "ne"
+    assert f.value == 1
+
+
+def test_chart_filter_rejects_invalid_column():
+    """Test that ChartFilter rejects invalid column names."""
+    with pytest.raises(ValidationError):
+        ChartFilter(col="nonexistent_column", opr="eq", value=42)
+
+
+def test_dashboard_filter_accepts_created_by_fk():
+    """Test that DashboardFilter accepts created_by_fk as a valid column."""
+    f = DashboardFilter(col="created_by_fk", opr="eq", value=42)
+    assert f.col == "created_by_fk"
+    assert f.opr == "eq"
+    assert f.value == 42
+
+
+def test_dashboard_filter_created_by_fk_with_ne_operator():
+    """Test created_by_fk with 'ne' (not equal) operator on dashboards."""
+    f = DashboardFilter(col="created_by_fk", opr="ne", value=1)
+    assert f.col == "created_by_fk"
+    assert f.opr == "ne"
+    assert f.value == 1
+
+
+def test_dashboard_filter_rejects_invalid_column():
+    """Test that DashboardFilter rejects invalid column names."""
+    with pytest.raises(ValidationError):
+        DashboardFilter(col="nonexistent_column", opr="eq", value=42)
+
+
+# ---------------------------------------------------------------------------
+# Existing filter columns still work
+# ---------------------------------------------------------------------------
+
+
+def test_chart_filter_existing_columns_still_work():
+    """Test that pre-existing chart filter columns are not broken."""
+    for col in ("slice_name", "viz_type", "datasource_name"):
+        f = ChartFilter(col=col, opr="eq", value="test")
+        assert f.col == col
+
+
+def test_dashboard_filter_existing_columns_still_work():
+    """Test that pre-existing dashboard filter columns are not broken."""
+    for col in ("dashboard_title", "published", "favorite"):
+        f = DashboardFilter(col=col, opr="eq", value="test")
+        assert f.col == col
diff --git a/tests/unit_tests/mcp_service/system/tool/test_get_schema.py 
b/tests/unit_tests/mcp_service/system/tool/test_get_schema.py
index e00c1cb9cd7..38c30a3e47d 100644
--- a/tests/unit_tests/mcp_service/system/tool/test_get_schema.py
+++ b/tests/unit_tests/mcp_service/system/tool/test_get_schema.py
@@ -236,9 +236,15 @@ class TestGetSchemaToolViaClient:
             assert "dashboard_title" in info["sortable_columns"]
             assert "changed_on" in info["sortable_columns"]
 
+    @patch(
+        "superset.mcp_service.utils.schema_utils._is_parse_request_enabled",
+        return_value=True,
+    )
     @patch("superset.daos.chart.ChartDAO.get_filterable_columns_and_operators")
     @pytest.mark.asyncio
-    async def test_get_schema_with_json_string_request(self, mock_filters, 
mcp_server):
+    async def test_get_schema_with_json_string_request(
+        self, mock_filters, mock_parse_enabled, mcp_server
+    ):
         """Test get_schema accepts JSON string request (Claude Code 
compatibility)."""
         mock_filters.return_value = {"slice_name": ["eq"]}
 
diff --git a/tests/unit_tests/mcp_service/utils/test_schema_utils.py 
b/tests/unit_tests/mcp_service/utils/test_schema_utils.py
index 4e592159c07..95b4c2bbbe4 100644
--- a/tests/unit_tests/mcp_service/utils/test_schema_utils.py
+++ b/tests/unit_tests/mcp_service/utils/test_schema_utils.py
@@ -354,6 +354,17 @@ class TestParseRequestDecorator:
         name: str
         count: int
 
+    @pytest.fixture(autouse=True)
+    def _enable_parse_request(self):
+        """Ensure MCP_PARSE_REQUEST_ENABLED=True for all parsing tests."""
+        from unittest.mock import patch
+
+        with patch(
+            
"superset.mcp_service.utils.schema_utils._is_parse_request_enabled",
+            return_value=True,
+        ):
+            yield
+
     def test_decorator_with_json_string_async(self):
         """Should parse JSON string request in async function."""
         from unittest.mock import MagicMock, patch

Reply via email to