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

aminghadersohi pushed a commit to branch mcp-users-roles-99978
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 38d28c330ecb9f2ea3eca32f79fc68e52b3e6de2
Author: Amin Ghadersohi <[email protected]>
AuthorDate: Wed May 20 22:48:43 2026 +0000

    feat(mcp): add list and get tools for users and roles
    
    Implements 4 new MCP tools across two domains:
    - list_users, get_user_info: admin-only user directory tools with
      privacy controls (email/roles redacted unless caller has data model
      metadata access)
    - list_roles, get_role_info: admin-only role listing tools backed by
      a minimal RoleDAO wrapping the FAB Role model
    
    Registers all tools in app.py and adds them to the server instructions.
    Unit tests cover basic listing, privacy enforcement, not-found paths,
    and filter/search mutual-exclusion validation.
---
 superset/mcp_service/app.py                        |  14 +
 superset/mcp_service/role/__init__.py              |  16 +
 superset/mcp_service/role/dao.py                   |  28 ++
 superset/mcp_service/role/schemas.py               | 219 +++++++++++++
 superset/mcp_service/role/tool/__init__.py         |  24 ++
 superset/mcp_service/role/tool/get_role_info.py    |  99 ++++++
 superset/mcp_service/role/tool/list_roles.py       | 126 ++++++++
 superset/mcp_service/user/__init__.py              |  16 +
 superset/mcp_service/user/schemas.py               | 271 ++++++++++++++++
 superset/mcp_service/user/tool/__init__.py         |  24 ++
 superset/mcp_service/user/tool/get_user_info.py    | 112 +++++++
 superset/mcp_service/user/tool/list_users.py       | 136 ++++++++
 tests/unit_tests/mcp_service/role/__init__.py      |  16 +
 tests/unit_tests/mcp_service/role/tool/__init__.py |  16 +
 .../mcp_service/role/tool/test_role_tools.py       | 244 ++++++++++++++
 tests/unit_tests/mcp_service/user/__init__.py      |  16 +
 tests/unit_tests/mcp_service/user/tool/__init__.py |  16 +
 .../mcp_service/user/tool/test_user_tools.py       | 359 +++++++++++++++++++++
 18 files changed, 1752 insertions(+)

diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py
index 0a68d168a07..ab98d7ad6c5 100644
--- a/superset/mcp_service/app.py
+++ b/superset/mcp_service/app.py
@@ -123,6 +123,12 @@ Database Connections:
 - list_databases: List database connections with advanced filters (1-based 
pagination)
 - get_database_info: Get detailed database connection info by ID (backend, 
capabilities)
 
+User and Role Management:
+- list_users: List users with filtering (1-based pagination, admin only)
+- get_user_info: Get user details by ID (admin only)
+- list_roles: List roles with filtering (1-based pagination, admin only)
+- get_role_info: Get role details by ID (admin only)
+
 Dataset Management:
 - list_datasets: List datasets with advanced filters (1-based pagination)
 - get_dataset_info: Get detailed dataset information by ID (includes 
columns/metrics)
@@ -620,6 +626,10 @@ from superset.mcp_service.dataset.tool import (  # noqa: 
F401, E402
 from superset.mcp_service.explore.tool import (  # noqa: F401, E402
     generate_explore_link,
 )
+from superset.mcp_service.role.tool import (  # noqa: F401, E402
+    get_role_info,
+    list_roles,
+)
 from superset.mcp_service.sql_lab.tool import (  # noqa: F401, E402
     execute_sql,
     open_sql_lab_with_context,
@@ -635,6 +645,10 @@ from superset.mcp_service.system.tool import (  # noqa: 
F401, E402
     get_schema,
     health_check,
 )
+from superset.mcp_service.user.tool import (  # noqa: F401, E402
+    get_user_info,
+    list_users,
+)
 
 
 def _remove_disabled_tools(disabled_tools: set[str]) -> None:
diff --git a/superset/mcp_service/role/__init__.py 
b/superset/mcp_service/role/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ b/superset/mcp_service/role/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git a/superset/mcp_service/role/dao.py b/superset/mcp_service/role/dao.py
new file mode 100644
index 00000000000..3dcda6a232b
--- /dev/null
+++ b/superset/mcp_service/role/dao.py
@@ -0,0 +1,28 @@
+# 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.
+
+"""Minimal DAO for FAB Role model."""
+
+from __future__ import annotations
+
+from flask_appbuilder.security.sqla.models import Role
+
+from superset.daos.base import BaseDAO
+
+
+class RoleDAO(BaseDAO[Role]):
+    """DAO for FAB Role model. Provides basic CRUD via BaseDAO."""
diff --git a/superset/mcp_service/role/schemas.py 
b/superset/mcp_service/role/schemas.py
new file mode 100644
index 00000000000..7f5e4be53a2
--- /dev/null
+++ b/superset/mcp_service/role/schemas.py
@@ -0,0 +1,219 @@
+# 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.
+
+"""Pydantic schemas for role-related MCP tool responses."""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import Annotated, Any, List, Literal
+
+from pydantic import (
+    BaseModel,
+    ConfigDict,
+    Field,
+    field_validator,
+    model_validator,
+    PositiveInt,
+)
+
+from superset.daos.base import ColumnOperator, ColumnOperatorEnum
+from superset.mcp_service.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
+from superset.mcp_service.system.schemas import PaginationInfo
+from superset.mcp_service.utils.schema_utils import (
+    parse_json_or_list,
+    parse_json_or_model_list,
+)
+
+DEFAULT_ROLE_COLUMNS = ["id", "name"]
+
+ROLE_ALL_COLUMNS = ["id", "name"]
+
+ROLE_SORTABLE_COLUMNS = ["id", "name"]
+
+
+class RoleFilter(ColumnOperator):
+    """Filter object for role listing.
+
+    col: The column to filter on. Must be one of the allowed filter fields.
+    opr: The operator to use. Must be one of the supported operators.
+    value: The value to filter by (type depends on col and opr).
+    """
+
+    col: Literal["name"] = Field(
+        ...,
+        description="Column to filter on.",
+    )
+    opr: ColumnOperatorEnum = Field(
+        ...,
+        description="Operator to use.",
+    )
+    value: str | int | float | bool | List[str | int | float | bool] = Field(
+        ..., description="Value to filter by (type depends on col and opr)"
+    )
+
+
+class RoleInfo(BaseModel):
+    id: int | None = Field(None, description="Role ID")
+    name: str | None = Field(None, description="Role name")
+    model_config = ConfigDict(
+        from_attributes=True,
+        ser_json_timedelta="iso8601",
+        populate_by_name=True,
+    )
+
+
+class RoleList(BaseModel):
+    roles: List[RoleInfo]
+    count: int
+    total_count: int
+    page: int
+    page_size: int
+    total_pages: int
+    has_previous: bool
+    has_next: bool
+    columns_requested: List[str] = Field(
+        default_factory=list,
+        description="Requested columns for the response",
+    )
+    columns_loaded: List[str] = Field(
+        default_factory=list,
+        description="Columns that were actually loaded for each role",
+    )
+    columns_available: List[str] = Field(
+        default_factory=list,
+        description="All columns available for selection via select_columns 
parameter",
+    )
+    sortable_columns: List[str] = Field(
+        default_factory=list,
+        description="Columns that can be used with order_column parameter",
+    )
+    filters_applied: List[RoleFilter] = Field(
+        default_factory=list,
+        description="List of advanced filter dicts applied to the query.",
+    )
+    pagination: PaginationInfo | None = None
+    timestamp: datetime | None = None
+    model_config = ConfigDict(ser_json_timedelta="iso8601")
+
+
+class ListRolesRequest(BaseModel):
+    """Request schema for list_roles."""
+
+    filters: Annotated[
+        List[RoleFilter],
+        Field(
+            default_factory=list,
+            description="List of filter objects (column, operator, value). 
Each "
+            "filter is an object with 'col', 'opr', and 'value' properties. "
+            "Cannot be used together with 'search'.",
+        ),
+    ]
+    select_columns: Annotated[
+        List[str],
+        Field(
+            default_factory=list,
+            description="List of columns to select. Defaults to common columns 
if "
+            "not specified.",
+        ),
+    ]
+    search: Annotated[
+        str | None,
+        Field(
+            default=None,
+            description="Text search string to match against role name. Cannot 
be "
+            "used together with 'filters'.",
+        ),
+    ]
+    order_column: Annotated[
+        str | None, Field(default=None, description="Column to order results 
by")
+    ]
+    order_direction: Annotated[
+        Literal["asc", "desc"],
+        Field(
+            default="asc", description="Direction to order results ('asc' or 
'desc')"
+        ),
+    ]
+    page: Annotated[
+        PositiveInt,
+        Field(default=1, description="Page number for pagination (1-based)"),
+    ]
+    page_size: Annotated[
+        int,
+        Field(
+            default=DEFAULT_PAGE_SIZE,
+            gt=0,
+            le=MAX_PAGE_SIZE,
+            description=f"Number of items per page (max {MAX_PAGE_SIZE})",
+        ),
+    ]
+
+    @field_validator("filters", mode="before")
+    @classmethod
+    def parse_filters(cls, v: Any) -> List[RoleFilter]:
+        """Accept both JSON string and list of objects."""
+        return parse_json_or_model_list(v, RoleFilter, "filters")
+
+    @field_validator("select_columns", mode="before")
+    @classmethod
+    def parse_columns(cls, v: Any) -> List[str]:
+        """Accept JSON array, list, or comma-separated string."""
+        return parse_json_or_list(v, "select_columns")
+
+    @model_validator(mode="after")
+    def validate_search_and_filters(self) -> "ListRolesRequest":
+        """Prevent using both search and filters simultaneously."""
+        if self.search and self.filters:
+            raise ValueError(
+                "Cannot use both 'search' and 'filters' parameters 
simultaneously. "
+                "Use either 'search' for text-based searching or 'filters' for 
"
+                "precise column-based filtering, but not both."
+            )
+        return self
+
+
+class RoleError(BaseModel):
+    error: str = Field(..., description="Error message")
+    error_type: str = Field(..., description="Type of error")
+    timestamp: str | datetime | None = Field(None, description="Error 
timestamp")
+    model_config = ConfigDict(ser_json_timedelta="iso8601")
+
+    @classmethod
+    def create(cls, error: str, error_type: str) -> "RoleError":
+        """Create a standardized RoleError with timestamp."""
+        return cls(
+            error=error, error_type=error_type, 
timestamp=datetime.now(timezone.utc)
+        )
+
+
+class GetRoleInfoRequest(BaseModel):
+    """Request schema for get_role_info."""
+
+    identifier: Annotated[
+        int,
+        Field(description="Role ID (integer)"),
+    ]
+
+
+def serialize_role_object(role: Any) -> RoleInfo | None:
+    """Serialize a FAB Role object into a RoleInfo schema."""
+    if not role:
+        return None
+    return RoleInfo(
+        id=getattr(role, "id", None),
+        name=getattr(role, "name", None),
+    )
diff --git a/superset/mcp_service/role/tool/__init__.py 
b/superset/mcp_service/role/tool/__init__.py
new file mode 100644
index 00000000000..bcff24110eb
--- /dev/null
+++ b/superset/mcp_service/role/tool/__init__.py
@@ -0,0 +1,24 @@
+# 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.
+
+from .get_role_info import get_role_info
+from .list_roles import list_roles
+
+__all__ = [
+    "list_roles",
+    "get_role_info",
+]
diff --git a/superset/mcp_service/role/tool/get_role_info.py 
b/superset/mcp_service/role/tool/get_role_info.py
new file mode 100644
index 00000000000..e007dcb3e19
--- /dev/null
+++ b/superset/mcp_service/role/tool/get_role_info.py
@@ -0,0 +1,99 @@
+# 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.
+
+"""Get role info FastMCP tool."""
+
+import logging
+from datetime import datetime, timezone
+
+from fastmcp import Context
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.extensions import event_logger
+from superset.mcp_service.mcp_core import ModelGetInfoCore
+from superset.mcp_service.role.schemas import (
+    GetRoleInfoRequest,
+    RoleError,
+    RoleInfo,
+    serialize_role_object,
+)
+
+logger = logging.getLogger(__name__)
+
+
+@tool(
+    tags=["discovery"],
+    class_permission_name="Role",
+    annotations=ToolAnnotations(
+        title="Get role info",
+        readOnlyHint=True,
+        destructiveHint=False,
+    ),
+)
+async def get_role_info(
+    request: GetRoleInfoRequest, ctx: Context
+) -> RoleInfo | RoleError:
+    """Get role details by ID. Admin only.
+
+    Returns role metadata including id and name.
+
+    Example usage:
+    ```json
+    {
+        "identifier": 1
+    }
+    ```
+    """
+    await ctx.info("Retrieving role information: identifier=%s" % 
(request.identifier,))
+
+    try:
+        from superset.mcp_service.role.dao import RoleDAO
+
+        with event_logger.log_context(action="mcp.get_role_info.lookup"):
+            get_tool = ModelGetInfoCore(
+                dao_class=RoleDAO,
+                output_schema=RoleInfo,
+                error_schema=RoleError,
+                serializer=serialize_role_object,
+                supports_slug=False,
+                logger=logger,
+            )
+            result = get_tool.run_tool(request.identifier)
+
+        if isinstance(result, RoleInfo):
+            await ctx.info(
+                "Role information retrieved successfully: role_id=%s, name=%s"
+                % (result.id, result.name)
+            )
+        else:
+            await ctx.warning(
+                "Role retrieval failed: error_type=%s, error=%s"
+                % (result.error_type, result.error)
+            )
+
+        return result
+
+    except Exception as e:
+        await ctx.error(
+            "Role information retrieval failed: identifier=%s, error=%s, 
error_type=%s"
+            % (request.identifier, str(e), type(e).__name__)
+        )
+        return RoleError(
+            error=f"Failed to get role info: {str(e)}",
+            error_type="InternalError",
+            timestamp=datetime.now(timezone.utc),
+        )
diff --git a/superset/mcp_service/role/tool/list_roles.py 
b/superset/mcp_service/role/tool/list_roles.py
new file mode 100644
index 00000000000..4d272e421ce
--- /dev/null
+++ b/superset/mcp_service/role/tool/list_roles.py
@@ -0,0 +1,126 @@
+# 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.
+
+"""List roles FastMCP tool."""
+
+import logging
+from typing import Any
+
+from fastmcp import Context
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.extensions import event_logger
+from superset.mcp_service.mcp_core import ModelListCore
+from superset.mcp_service.role.schemas import (
+    DEFAULT_ROLE_COLUMNS,
+    ListRolesRequest,
+    ROLE_ALL_COLUMNS,
+    ROLE_SORTABLE_COLUMNS,
+    RoleError,
+    RoleFilter,
+    RoleInfo,
+    RoleList,
+    serialize_role_object,
+)
+
+logger = logging.getLogger(__name__)
+
+_DEFAULT_LIST_ROLES_REQUEST = ListRolesRequest()
+
+
+@tool(
+    tags=["core"],
+    class_permission_name="Role",
+    annotations=ToolAnnotations(
+        title="List roles",
+        readOnlyHint=True,
+        destructiveHint=False,
+    ),
+)
+async def list_roles(
+    request: ListRolesRequest | None = None,
+    ctx: Context | None = None,
+) -> RoleList | RoleError:
+    """List roles with filtering and search. Admin only.
+
+    Returns role metadata including id and name.
+
+    Sortable columns for order_column: id, name
+    """
+    if ctx is None:
+        raise RuntimeError("FastMCP context is required for list_roles")
+
+    request = request or _DEFAULT_LIST_ROLES_REQUEST.model_copy(deep=True)
+
+    await ctx.info(
+        "Listing roles: page=%s, page_size=%s, search=%s"
+        % (request.page, request.page_size, request.search)
+    )
+    await ctx.debug(
+        "Role listing parameters: filters=%s, order_column=%s, 
order_direction=%s"
+        % (request.filters, request.order_column, request.order_direction)
+    )
+
+    try:
+        from superset.mcp_service.role.dao import RoleDAO
+
+        def _serialize_role(obj: Any, cols: list[str] | None) -> RoleInfo | 
None:
+            return serialize_role_object(obj)
+
+        list_tool = ModelListCore(
+            dao_class=RoleDAO,
+            output_schema=RoleInfo,
+            item_serializer=_serialize_role,
+            filter_type=RoleFilter,
+            default_columns=DEFAULT_ROLE_COLUMNS,
+            search_columns=["name"],
+            list_field_name="roles",
+            output_list_schema=RoleList,
+            all_columns=ROLE_ALL_COLUMNS,
+            sortable_columns=ROLE_SORTABLE_COLUMNS,
+            logger=logger,
+        )
+
+        with event_logger.log_context(action="mcp.list_roles.query"):
+            result = list_tool.run_tool(
+                filters=request.filters,
+                search=request.search,
+                select_columns=request.select_columns,
+                order_column=request.order_column or "id",
+                order_direction=request.order_direction,
+                page=max(request.page - 1, 0),
+                page_size=request.page_size,
+            )
+
+        count = len(result.roles) if hasattr(result, "roles") else 0
+        await ctx.info(
+            "Roles listed successfully: count=%s, total_count=%s, 
total_pages=%s"
+            % (
+                count,
+                getattr(result, "total_count", None),
+                getattr(result, "total_pages", None),
+            )
+        )
+
+        return result
+
+    except Exception as e:
+        await ctx.error(
+            "Role listing failed: page=%s, page_size=%s, error=%s, 
error_type=%s"
+            % (request.page, request.page_size, str(e), type(e).__name__)
+        )
+        raise
diff --git a/superset/mcp_service/user/__init__.py 
b/superset/mcp_service/user/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ b/superset/mcp_service/user/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git a/superset/mcp_service/user/schemas.py 
b/superset/mcp_service/user/schemas.py
new file mode 100644
index 00000000000..68b3c9c8ca5
--- /dev/null
+++ b/superset/mcp_service/user/schemas.py
@@ -0,0 +1,271 @@
+# 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.
+
+"""Pydantic schemas for user-related MCP tool responses."""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import Annotated, Any, List, Literal
+
+from pydantic import (
+    BaseModel,
+    ConfigDict,
+    Field,
+    field_validator,
+    model_validator,
+    PositiveInt,
+)
+
+from superset.daos.base import ColumnOperator, ColumnOperatorEnum
+from superset.mcp_service.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
+from superset.mcp_service.system.schemas import PaginationInfo
+from superset.mcp_service.utils.schema_utils import (
+    parse_json_or_list,
+    parse_json_or_model_list,
+)
+
+DEFAULT_USER_COLUMNS = ["id", "username", "first_name", "last_name", "active"]
+
+USER_ALL_COLUMNS = [
+    "id",
+    "username",
+    "first_name",
+    "last_name",
+    "active",
+    "email",
+    "changed_on",
+]
+
+USER_SORTABLE_COLUMNS = [
+    "id",
+    "username",
+    "first_name",
+    "last_name",
+    "active",
+    "changed_on",
+]
+
+
+class UserFilter(ColumnOperator):
+    """Filter object for user listing.
+
+    col: The column to filter on. Must be one of the allowed filter fields.
+    opr: The operator to use. Must be one of the supported operators.
+    value: The value to filter by (type depends on col and opr).
+    """
+
+    col: Literal["username", "first_name", "last_name", "active"] = Field(
+        ...,
+        description="Column to filter on.",
+    )
+    opr: ColumnOperatorEnum = Field(
+        ...,
+        description="Operator to use.",
+    )
+    value: str | int | float | bool | List[str | int | float | bool] = Field(
+        ..., description="Value to filter by (type depends on col and opr)"
+    )
+
+
+class UserInfo(BaseModel):
+    id: int | None = Field(None, description="User ID")
+    username: str | None = Field(None, description="Username")
+    first_name: str | None = Field(None, description="First name")
+    last_name: str | None = Field(None, description="Last name")
+    active: bool | None = Field(None, description="Whether the user account is 
active")
+    email: str | None = Field(
+        None,
+        description="Email address (only returned with data model metadata 
access)",
+    )
+    roles: list[str] | None = Field(
+        None,
+        description="Assigned role names (only returned with data model "
+        "metadata access)",
+    )
+    changed_on: str | datetime | None = Field(
+        None, description="Last modification timestamp"
+    )
+    model_config = ConfigDict(
+        from_attributes=True,
+        ser_json_timedelta="iso8601",
+        populate_by_name=True,
+    )
+
+
+class UserList(BaseModel):
+    users: List[UserInfo]
+    count: int
+    total_count: int
+    page: int
+    page_size: int
+    total_pages: int
+    has_previous: bool
+    has_next: bool
+    columns_requested: List[str] = Field(
+        default_factory=list,
+        description="Requested columns for the response",
+    )
+    columns_loaded: List[str] = Field(
+        default_factory=list,
+        description="Columns that were actually loaded for each user",
+    )
+    columns_available: List[str] = Field(
+        default_factory=list,
+        description="All columns available for selection via select_columns 
parameter",
+    )
+    sortable_columns: List[str] = Field(
+        default_factory=list,
+        description="Columns that can be used with order_column parameter",
+    )
+    filters_applied: List[UserFilter] = Field(
+        default_factory=list,
+        description="List of advanced filter dicts applied to the query.",
+    )
+    pagination: PaginationInfo | None = None
+    timestamp: datetime | None = None
+    model_config = ConfigDict(ser_json_timedelta="iso8601")
+
+
+class ListUsersRequest(BaseModel):
+    """Request schema for list_users."""
+
+    filters: Annotated[
+        List[UserFilter],
+        Field(
+            default_factory=list,
+            description="List of filter objects (column, operator, value). 
Each "
+            "filter is an object with 'col', 'opr', and 'value' properties. "
+            "Cannot be used together with 'search'.",
+        ),
+    ]
+    select_columns: Annotated[
+        List[str],
+        Field(
+            default_factory=list,
+            description="List of columns to select. Defaults to common columns 
if "
+            "not specified.",
+        ),
+    ]
+    search: Annotated[
+        str | None,
+        Field(
+            default=None,
+            description="Text search string to match against user fields. 
Cannot be "
+            "used together with 'filters'.",
+        ),
+    ]
+    order_column: Annotated[
+        str | None, Field(default=None, description="Column to order results 
by")
+    ]
+    order_direction: Annotated[
+        Literal["asc", "desc"],
+        Field(
+            default="asc", description="Direction to order results ('asc' or 
'desc')"
+        ),
+    ]
+    page: Annotated[
+        PositiveInt,
+        Field(default=1, description="Page number for pagination (1-based)"),
+    ]
+    page_size: Annotated[
+        int,
+        Field(
+            default=DEFAULT_PAGE_SIZE,
+            gt=0,
+            le=MAX_PAGE_SIZE,
+            description=f"Number of items per page (max {MAX_PAGE_SIZE})",
+        ),
+    ]
+
+    @field_validator("filters", mode="before")
+    @classmethod
+    def parse_filters(cls, v: Any) -> List[UserFilter]:
+        """Accept both JSON string and list of objects."""
+        return parse_json_or_model_list(v, UserFilter, "filters")
+
+    @field_validator("select_columns", mode="before")
+    @classmethod
+    def parse_columns(cls, v: Any) -> List[str]:
+        """Accept JSON array, list, or comma-separated string."""
+        return parse_json_or_list(v, "select_columns")
+
+    @model_validator(mode="after")
+    def validate_search_and_filters(self) -> "ListUsersRequest":
+        """Prevent using both search and filters simultaneously."""
+        if self.search and self.filters:
+            raise ValueError(
+                "Cannot use both 'search' and 'filters' parameters 
simultaneously. "
+                "Use either 'search' for text-based searching or 'filters' for 
"
+                "precise column-based filtering, but not both."
+            )
+        return self
+
+
+class UserError(BaseModel):
+    error: str = Field(..., description="Error message")
+    error_type: str = Field(..., description="Type of error")
+    timestamp: str | datetime | None = Field(None, description="Error 
timestamp")
+    model_config = ConfigDict(ser_json_timedelta="iso8601")
+
+    @classmethod
+    def create(cls, error: str, error_type: str) -> "UserError":
+        """Create a standardized UserError with timestamp."""
+        return cls(
+            error=error, error_type=error_type, 
timestamp=datetime.now(timezone.utc)
+        )
+
+
+class GetUserInfoRequest(BaseModel):
+    """Request schema for get_user_info."""
+
+    identifier: Annotated[
+        int,
+        Field(description="User ID (integer)"),
+    ]
+
+
+def serialize_user_object(
+    user: Any, include_sensitive: bool = False
+) -> UserInfo | None:
+    """Serialize a FAB User object into a UserInfo schema.
+
+    Sensitive fields (email, roles) are only included when 
include_sensitive=True,
+    which should reflect whether the caller has data model metadata access.
+    """
+    if not user:
+        return None
+
+    roles: list[str] | None = None
+    if include_sensitive:
+        user_roles = getattr(user, "roles", None)
+        if user_roles is not None:
+            try:
+                roles = [r.name for r in user_roles if hasattr(r, "name")]
+            except Exception:  # noqa: BLE001
+                roles = None
+
+    return 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),
+        active=getattr(user, "active", None),
+        email=getattr(user, "email", None) if include_sensitive else None,
+        roles=roles,
+        changed_on=getattr(user, "changed_on", None),
+    )
diff --git a/superset/mcp_service/user/tool/__init__.py 
b/superset/mcp_service/user/tool/__init__.py
new file mode 100644
index 00000000000..ec332f4d905
--- /dev/null
+++ b/superset/mcp_service/user/tool/__init__.py
@@ -0,0 +1,24 @@
+# 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.
+
+from .get_user_info import get_user_info
+from .list_users import list_users
+
+__all__ = [
+    "list_users",
+    "get_user_info",
+]
diff --git a/superset/mcp_service/user/tool/get_user_info.py 
b/superset/mcp_service/user/tool/get_user_info.py
new file mode 100644
index 00000000000..98f0d4b8f28
--- /dev/null
+++ b/superset/mcp_service/user/tool/get_user_info.py
@@ -0,0 +1,112 @@
+# 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.
+
+"""Get user info FastMCP tool."""
+
+import logging
+from datetime import datetime, timezone
+
+from fastmcp import Context
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.extensions import event_logger
+from superset.mcp_service.mcp_core import ModelGetInfoCore
+from superset.mcp_service.privacy import user_can_view_data_model_metadata
+from superset.mcp_service.user.schemas import (
+    GetUserInfoRequest,
+    serialize_user_object,
+    UserError,
+    UserInfo,
+)
+
+logger = logging.getLogger(__name__)
+
+
+@tool(
+    tags=["discovery"],
+    class_permission_name="User",
+    annotations=ToolAnnotations(
+        title="Get user info",
+        readOnlyHint=True,
+        destructiveHint=False,
+    ),
+)
+async def get_user_info(
+    request: GetUserInfoRequest, ctx: Context
+) -> UserInfo | UserError:
+    """Get user details by ID. Admin only.
+
+    Returns user metadata including username, name, and active status.
+    Sensitive fields (email, roles) are only included when the caller has
+    data model metadata access.
+
+    Example usage:
+    ```json
+    {
+        "identifier": 1
+    }
+    ```
+    """
+    await ctx.info("Retrieving user information: identifier=%s" % 
(request.identifier,))
+
+    try:
+        from superset.daos.user import UserDAO
+
+        can_view_sensitive = user_can_view_data_model_metadata()
+
+        if not can_view_sensitive:
+            await ctx.debug(
+                "Sensitive fields (email, roles) will be redacted for this 
caller"
+            )
+
+        def _serializer(obj: object) -> UserInfo | None:
+            return serialize_user_object(obj, 
include_sensitive=can_view_sensitive)
+
+        with event_logger.log_context(action="mcp.get_user_info.lookup"):
+            get_tool = ModelGetInfoCore(
+                dao_class=UserDAO,
+                output_schema=UserInfo,
+                error_schema=UserError,
+                serializer=_serializer,
+                supports_slug=False,
+                logger=logger,
+            )
+            result = get_tool.run_tool(request.identifier)
+
+        if isinstance(result, UserInfo):
+            await ctx.info(
+                "User information retrieved successfully: user_id=%s, 
username=%s"
+                % (result.id, result.username)
+            )
+        else:
+            await ctx.warning(
+                "User retrieval failed: error_type=%s, error=%s"
+                % (result.error_type, result.error)
+            )
+
+        return result
+
+    except Exception as e:
+        await ctx.error(
+            "User information retrieval failed: identifier=%s, error=%s, 
error_type=%s"
+            % (request.identifier, str(e), type(e).__name__)
+        )
+        return UserError(
+            error=f"Failed to get user info: {str(e)}",
+            error_type="InternalError",
+            timestamp=datetime.now(timezone.utc),
+        )
diff --git a/superset/mcp_service/user/tool/list_users.py 
b/superset/mcp_service/user/tool/list_users.py
new file mode 100644
index 00000000000..213079dc09e
--- /dev/null
+++ b/superset/mcp_service/user/tool/list_users.py
@@ -0,0 +1,136 @@
+# 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.
+
+"""List users FastMCP tool."""
+
+import logging
+from typing import Any
+
+from fastmcp import Context
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.extensions import event_logger
+from superset.mcp_service.mcp_core import ModelListCore
+from superset.mcp_service.privacy import user_can_view_data_model_metadata
+from superset.mcp_service.user.schemas import (
+    DEFAULT_USER_COLUMNS,
+    ListUsersRequest,
+    serialize_user_object,
+    USER_ALL_COLUMNS,
+    USER_SORTABLE_COLUMNS,
+    UserError,
+    UserFilter,
+    UserInfo,
+    UserList,
+)
+
+logger = logging.getLogger(__name__)
+
+_DEFAULT_LIST_USERS_REQUEST = ListUsersRequest()
+
+
+@tool(
+    tags=["core"],
+    class_permission_name="User",
+    annotations=ToolAnnotations(
+        title="List users",
+        readOnlyHint=True,
+        destructiveHint=False,
+    ),
+)
+async def list_users(
+    request: ListUsersRequest | None = None,
+    ctx: Context | None = None,
+) -> UserList | UserError:
+    """List users with filtering and search. Admin only.
+
+    Returns user metadata. Sensitive fields (email, roles) are only included
+    when the caller has data model metadata access.
+
+    Sortable columns for order_column: id, username, first_name, last_name,
+    active, changed_on
+    """
+    if ctx is None:
+        raise RuntimeError("FastMCP context is required for list_users")
+
+    request = request or _DEFAULT_LIST_USERS_REQUEST.model_copy(deep=True)
+
+    await ctx.info(
+        "Listing users: page=%s, page_size=%s, search=%s"
+        % (request.page, request.page_size, request.search)
+    )
+    await ctx.debug(
+        "User listing parameters: filters=%s, order_column=%s, 
order_direction=%s"
+        % (request.filters, request.order_column, request.order_direction)
+    )
+
+    try:
+        from superset.daos.user import UserDAO
+
+        can_view_sensitive = user_can_view_data_model_metadata()
+
+        if not can_view_sensitive:
+            await ctx.debug(
+                "Sensitive fields (email, roles) will be redacted for this 
caller"
+            )
+
+        def _serialize_user(obj: Any, cols: list[str] | None) -> UserInfo | 
None:
+            return serialize_user_object(obj, 
include_sensitive=can_view_sensitive)
+
+        list_tool = ModelListCore(
+            dao_class=UserDAO,
+            output_schema=UserInfo,
+            item_serializer=_serialize_user,
+            filter_type=UserFilter,
+            default_columns=DEFAULT_USER_COLUMNS,
+            search_columns=["username", "first_name", "last_name"],
+            list_field_name="users",
+            output_list_schema=UserList,
+            all_columns=USER_ALL_COLUMNS,
+            sortable_columns=USER_SORTABLE_COLUMNS,
+            logger=logger,
+        )
+
+        with event_logger.log_context(action="mcp.list_users.query"):
+            result = list_tool.run_tool(
+                filters=request.filters,
+                search=request.search,
+                select_columns=request.select_columns,
+                order_column=request.order_column or "id",
+                order_direction=request.order_direction,
+                page=max(request.page - 1, 0),
+                page_size=request.page_size,
+            )
+
+        count = len(result.users) if hasattr(result, "users") else 0
+        await ctx.info(
+            "Users listed successfully: count=%s, total_count=%s, 
total_pages=%s"
+            % (
+                count,
+                getattr(result, "total_count", None),
+                getattr(result, "total_pages", None),
+            )
+        )
+
+        return result
+
+    except Exception as e:
+        await ctx.error(
+            "User listing failed: page=%s, page_size=%s, error=%s, 
error_type=%s"
+            % (request.page, request.page_size, str(e), type(e).__name__)
+        )
+        raise
diff --git a/tests/unit_tests/mcp_service/role/__init__.py 
b/tests/unit_tests/mcp_service/role/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ b/tests/unit_tests/mcp_service/role/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git a/tests/unit_tests/mcp_service/role/tool/__init__.py 
b/tests/unit_tests/mcp_service/role/tool/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ b/tests/unit_tests/mcp_service/role/tool/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git a/tests/unit_tests/mcp_service/role/tool/test_role_tools.py 
b/tests/unit_tests/mcp_service/role/tool/test_role_tools.py
new file mode 100644
index 00000000000..36cde80ce97
--- /dev/null
+++ b/tests/unit_tests/mcp_service/role/tool/test_role_tools.py
@@ -0,0 +1,244 @@
+# 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.
+
+"""Unit tests for list_roles and get_role_info MCP tools."""
+
+from unittest.mock import MagicMock, Mock, patch
+
+import pytest
+from fastmcp import Client
+from pydantic import ValidationError
+
+from superset.mcp_service.app import mcp
+from superset.mcp_service.role.schemas import ListRolesRequest, RoleFilter
+from superset.utils import json
+
+
+def create_mock_role(role_id: int = 1, name: str = "Admin") -> MagicMock:
+    """Factory for mock FAB Role objects."""
+    role = MagicMock()
+    role.id = role_id
+    role.name = name
+    return role
+
+
[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
+
+
+# ---------------------------------------------------------------------------
+# Schema validation tests
+# ---------------------------------------------------------------------------
+
+
+class TestRoleFilterSchema:
+    def test_invalid_filter_column_rejected(self):
+        with pytest.raises(ValidationError):
+            RoleFilter(col="permissions", opr="eq", value="x")
+
+    def test_valid_filter_column_accepted(self):
+        f = RoleFilter(col="name", opr="eq", value="Admin")
+        assert f.col == "name"
+
+    def test_id_is_rejected_as_filter_column(self):
+        """id is not in the allowed filter columns for roles."""
+        with pytest.raises(ValidationError):
+            RoleFilter(col="id", opr="eq", value=1)
+
+
+# ---------------------------------------------------------------------------
+# list_roles tool tests
+# ---------------------------------------------------------------------------
+
+
+@patch("superset.mcp_service.role.dao.RoleDAO.list")
[email protected]
+async def test_list_roles_basic(mock_list, mcp_server):
+    """Basic role listing returns expected fields."""
+    role = create_mock_role()
+    mock_list.return_value = ([role], 1)
+
+    async with Client(mcp_server) as client:
+        result = await client.call_tool("list_roles", {})
+
+    data = json.loads(result.content[0].text)
+    assert data["roles"] is not None
+    assert len(data["roles"]) == 1
+    assert data["roles"][0]["id"] == 1
+    assert data["roles"][0]["name"] == "Admin"
+
+
+@patch("superset.mcp_service.role.dao.RoleDAO.list")
[email protected]
+async def test_list_roles_with_request(mock_list, mcp_server):
+    """list_roles accepts an explicit request object."""
+    role = create_mock_role(role_id=2, name="Alpha")
+    mock_list.return_value = ([role], 1)
+
+    async with Client(mcp_server) as client:
+        request = ListRolesRequest(page=1, page_size=5)
+        result = await client.call_tool("list_roles", {"request": 
request.model_dump()})
+
+    data = json.loads(result.content[0].text)
+    assert len(data["roles"]) == 1
+    assert data["roles"][0]["name"] == "Alpha"
+
+
+@patch("superset.mcp_service.role.dao.RoleDAO.list")
[email protected]
+async def test_list_roles_with_search(mock_list, mcp_server):
+    """list_roles passes search to the DAO."""
+    role = create_mock_role(name="Gamma")
+    mock_list.return_value = ([role], 1)
+
+    async with Client(mcp_server) as client:
+        request = ListRolesRequest(search="Gamma")
+        result = await client.call_tool("list_roles", {"request": 
request.model_dump()})
+
+    data = json.loads(result.content[0].text)
+    assert data["roles"][0]["name"] == "Gamma"
+
+
+@patch("superset.mcp_service.role.dao.RoleDAO.list")
[email protected]
+async def test_list_roles_with_name_filter(mock_list, mcp_server):
+    """list_roles accepts name column filters."""
+    role = create_mock_role(name="Viewer")
+    mock_list.return_value = ([role], 1)
+
+    async with Client(mcp_server) as client:
+        request = ListRolesRequest(
+            filters=[{"col": "name", "opr": "eq", "value": "Viewer"}]
+        )
+        result = await client.call_tool("list_roles", {"request": 
request.model_dump()})
+
+    data = json.loads(result.content[0].text)
+    assert len(data["roles"]) == 1
+    assert data["roles"][0]["name"] == "Viewer"
+
+
+@patch("superset.mcp_service.role.dao.RoleDAO.list")
[email protected]
+async def test_list_roles_empty_result(mock_list, mcp_server):
+    """list_roles handles empty results gracefully."""
+    mock_list.return_value = ([], 0)
+
+    async with Client(mcp_server) as client:
+        result = await client.call_tool("list_roles", {})
+
+    data = json.loads(result.content[0].text)
+    assert data["roles"] == []
+    assert data["count"] == 0
+    assert data["total_count"] == 0
+
+
+@patch("superset.mcp_service.role.dao.RoleDAO.list")
[email protected]
+async def test_list_roles_pagination(mock_list, mcp_server):
+    """list_roles returns correct pagination metadata."""
+    roles = [create_mock_role(role_id=i, name=f"Role{i}") for i in range(1, 4)]
+    mock_list.return_value = (roles, 10)
+
+    async with Client(mcp_server) as client:
+        request = ListRolesRequest(page=1, page_size=3)
+        result = await client.call_tool("list_roles", {"request": 
request.model_dump()})
+
+    data = json.loads(result.content[0].text)
+    assert data["count"] == 3
+    assert data["total_count"] == 10
+    assert data["page"] == 1
+    assert data["page_size"] == 3
+
+
[email protected]
+async def test_list_roles_search_and_filters_mutually_exclusive(mcp_server):
+    """search and filters cannot be used together."""
+    async with Client(mcp_server) as client:
+        result = await client.call_tool(
+            "list_roles",
+            {
+                "request": {
+                    "search": "Admin",
+                    "filters": [{"col": "name", "opr": "eq", "value": 
"Admin"}],
+                }
+            },
+        )
+
+    data = json.loads(result.content[0].text)
+    assert "error" in data or result.is_error
+
+
+# ---------------------------------------------------------------------------
+# get_role_info tool tests
+# ---------------------------------------------------------------------------
+
+
+@patch("superset.mcp_service.role.dao.RoleDAO.find_by_id")
[email protected]
+async def test_get_role_info_success(mock_find, mcp_server):
+    """get_role_info returns role details for a known ID."""
+    role = create_mock_role(role_id=1, name="Admin")
+    mock_find.return_value = role
+
+    async with Client(mcp_server) as client:
+        result = await client.call_tool("get_role_info", {"request": 
{"identifier": 1}})
+
+    data = json.loads(result.content[0].text)
+    assert data["id"] == 1
+    assert data["name"] == "Admin"
+
+
+@patch("superset.mcp_service.role.dao.RoleDAO.find_by_id")
[email protected]
+async def test_get_role_info_not_found(mock_find, mcp_server):
+    """get_role_info returns a not_found error for unknown IDs."""
+    mock_find.return_value = None
+
+    async with Client(mcp_server) as client:
+        result = await client.call_tool(
+            "get_role_info", {"request": {"identifier": 9999}}
+        )
+
+    data = json.loads(result.content[0].text)
+    assert data["error_type"] == "not_found"
+
+
+@patch("superset.mcp_service.role.dao.RoleDAO.find_by_id")
[email protected]
+async def test_get_role_info_returns_id_and_name(mock_find, mcp_server):
+    """get_role_info returns exactly id and name."""
+    role = create_mock_role(role_id=3, name="Gamma")
+    mock_find.return_value = role
+
+    async with Client(mcp_server) as client:
+        result = await client.call_tool("get_role_info", {"request": 
{"identifier": 3}})
+
+    data = json.loads(result.content[0].text)
+    assert data["id"] == 3
+    assert data["name"] == "Gamma"
diff --git a/tests/unit_tests/mcp_service/user/__init__.py 
b/tests/unit_tests/mcp_service/user/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ b/tests/unit_tests/mcp_service/user/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git a/tests/unit_tests/mcp_service/user/tool/__init__.py 
b/tests/unit_tests/mcp_service/user/tool/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ b/tests/unit_tests/mcp_service/user/tool/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git a/tests/unit_tests/mcp_service/user/tool/test_user_tools.py 
b/tests/unit_tests/mcp_service/user/tool/test_user_tools.py
new file mode 100644
index 00000000000..ab7f6f733e5
--- /dev/null
+++ b/tests/unit_tests/mcp_service/user/tool/test_user_tools.py
@@ -0,0 +1,359 @@
+# 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.
+
+"""Unit tests for list_users and get_user_info MCP tools."""
+
+import importlib
+from unittest.mock import MagicMock, Mock, patch
+
+import pytest
+from fastmcp import Client
+from pydantic import ValidationError
+
+from superset.mcp_service.app import mcp
+from superset.mcp_service.user.schemas import ListUsersRequest, UserFilter
+from superset.utils import json
+
+list_users_module = 
importlib.import_module("superset.mcp_service.user.tool.list_users")
+get_user_info_module = importlib.import_module(
+    "superset.mcp_service.user.tool.get_user_info"
+)
+
+
+def create_mock_user(
+    user_id: int = 1,
+    username: str = "admin",
+    first_name: str = "Admin",
+    last_name: str = "User",
+    active: bool = True,
+    email: str = "[email protected]",
+    roles: list[str] | None = None,
+) -> MagicMock:
+    """Factory for mock FAB User objects."""
+    user = MagicMock()
+    user.id = user_id
+    user.username = username
+    user.first_name = first_name
+    user.last_name = last_name
+    user.active = active
+    user.email = email
+    user.changed_on = None
+    user.created_on = None
+
+    mock_roles = []
+    for role_name in roles or []:
+        role = MagicMock()
+        role.name = role_name
+        mock_roles.append(role)
+    user.roles = mock_roles
+
+    return user
+
+
[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
+
+
[email protected](autouse=True)
+def allow_data_model_metadata():
+    """Keep user tests in the metadata-allowed path by default."""
+    with (
+        patch.object(
+            list_users_module,
+            "user_can_view_data_model_metadata",
+            return_value=True,
+        ),
+        patch.object(
+            get_user_info_module,
+            "user_can_view_data_model_metadata",
+            return_value=True,
+        ),
+    ):
+        yield
+
+
+# ---------------------------------------------------------------------------
+# Schema validation tests
+# ---------------------------------------------------------------------------
+
+
+class TestUserFilterSchema:
+    def test_invalid_filter_column_rejected(self):
+        with pytest.raises(ValidationError):
+            UserFilter(col="not_a_real_column", opr="eq", value="x")
+
+    def test_email_is_rejected_as_filter_column(self):
+        """email is not a public filter column."""
+        with pytest.raises(ValidationError):
+            UserFilter(col="email", opr="eq", value="x")
+
+    def test_valid_filter_column_accepted(self):
+        f = UserFilter(col="username", opr="eq", value="admin")
+        assert f.col == "username"
+
+
+# ---------------------------------------------------------------------------
+# list_users tool tests
+# ---------------------------------------------------------------------------
+
+
+@patch("superset.daos.user.UserDAO.list")
[email protected]
+async def test_list_users_basic(mock_list, mcp_server):
+    """Basic user listing returns expected fields."""
+    user = create_mock_user()
+    mock_list.return_value = ([user], 1)
+
+    async with Client(mcp_server) as client:
+        result = await client.call_tool("list_users", {})
+
+    data = json.loads(result.content[0].text)
+    assert data["users"] is not None
+    assert len(data["users"]) == 1
+    assert data["users"][0]["id"] == 1
+    assert data["users"][0]["username"] == "admin"
+    assert data["users"][0]["first_name"] == "Admin"
+    assert data["users"][0]["last_name"] == "User"
+    assert data["users"][0]["active"] is True
+
+
+@patch("superset.daos.user.UserDAO.list")
[email protected]
+async def test_list_users_with_request(mock_list, mcp_server):
+    """list_users accepts an explicit request object."""
+    user = create_mock_user(username="alice")
+    mock_list.return_value = ([user], 1)
+
+    async with Client(mcp_server) as client:
+        request = ListUsersRequest(page=1, page_size=5)
+        result = await client.call_tool("list_users", {"request": 
request.model_dump()})
+
+    data = json.loads(result.content[0].text)
+    assert len(data["users"]) == 1
+    assert data["users"][0]["username"] == "alice"
+
+
+@patch("superset.daos.user.UserDAO.list")
[email protected]
+async def test_list_users_with_search(mock_list, mcp_server):
+    """list_users passes search to the DAO."""
+    user = create_mock_user(username="alice")
+    mock_list.return_value = ([user], 1)
+
+    async with Client(mcp_server) as client:
+        request = ListUsersRequest(search="alice")
+        result = await client.call_tool("list_users", {"request": 
request.model_dump()})
+
+    data = json.loads(result.content[0].text)
+    assert data["users"][0]["username"] == "alice"
+
+
+@patch("superset.daos.user.UserDAO.list")
[email protected]
+async def test_list_users_with_filter(mock_list, mcp_server):
+    """list_users accepts column filters."""
+    user = create_mock_user(active=True)
+    mock_list.return_value = ([user], 1)
+
+    async with Client(mcp_server) as client:
+        request = ListUsersRequest(
+            filters=[{"col": "active", "opr": "eq", "value": True}]
+        )
+        result = await client.call_tool("list_users", {"request": 
request.model_dump()})
+
+    data = json.loads(result.content[0].text)
+    assert len(data["users"]) == 1
+
+
+@patch("superset.daos.user.UserDAO.list")
[email protected]
+async def test_list_users_includes_sensitive_fields_when_allowed(mock_list, 
mcp_server):
+    """email and roles are returned when caller has metadata access."""
+    role_mock = MagicMock()
+    role_mock.name = "Admin"
+    user = create_mock_user(email="[email protected]", roles=["Admin"])
+    mock_list.return_value = ([user], 1)
+
+    async with Client(mcp_server) as client:
+        result = await client.call_tool("list_users", {})
+
+    data = json.loads(result.content[0].text)
+    assert data["users"][0]["email"] == "[email protected]"
+    assert data["users"][0]["roles"] == ["Admin"]
+
+
+@patch("superset.daos.user.UserDAO.list")
[email protected]
+async def test_list_users_redacts_sensitive_fields_when_denied(mock_list, 
mcp_server):
+    """email and roles are redacted when caller lacks metadata access."""
+    user = create_mock_user(email="[email protected]", roles=["Admin"])
+    mock_list.return_value = ([user], 1)
+
+    with patch.object(
+        list_users_module,
+        "user_can_view_data_model_metadata",
+        return_value=False,
+    ):
+        async with Client(mcp_server) as client:
+            result = await client.call_tool("list_users", {})
+
+    data = json.loads(result.content[0].text)
+    assert data["users"][0]["email"] is None
+    assert data["users"][0]["roles"] is None
+
+
+@patch("superset.daos.user.UserDAO.list")
[email protected]
+async def test_list_users_empty_result(mock_list, mcp_server):
+    """list_users handles empty results gracefully."""
+    mock_list.return_value = ([], 0)
+
+    async with Client(mcp_server) as client:
+        result = await client.call_tool("list_users", {})
+
+    data = json.loads(result.content[0].text)
+    assert data["users"] == []
+    assert data["count"] == 0
+    assert data["total_count"] == 0
+
+
[email protected]
+async def test_list_users_search_and_filters_mutually_exclusive(mcp_server):
+    """search and filters cannot be used together."""
+    async with Client(mcp_server) as client:
+        result = await client.call_tool(
+            "list_users",
+            {
+                "request": {
+                    "search": "alice",
+                    "filters": [{"col": "active", "opr": "eq", "value": True}],
+                }
+            },
+        )
+
+    data = json.loads(result.content[0].text)
+    # Pydantic validation error should surface as an error result
+    assert "error" in data or result.is_error
+
+
+# ---------------------------------------------------------------------------
+# get_user_info tool tests
+# ---------------------------------------------------------------------------
+
+
+@patch("superset.daos.user.UserDAO.find_by_id")
[email protected]
+async def test_get_user_info_success(mock_find, mcp_server):
+    """get_user_info returns user details for a known ID."""
+    user = create_mock_user(user_id=1, username="admin")
+    mock_find.return_value = user
+
+    async with Client(mcp_server) as client:
+        result = await client.call_tool("get_user_info", {"request": 
{"identifier": 1}})
+
+    data = json.loads(result.content[0].text)
+    assert data["id"] == 1
+    assert data["username"] == "admin"
+
+
+@patch("superset.daos.user.UserDAO.find_by_id")
[email protected]
+async def test_get_user_info_not_found(mock_find, mcp_server):
+    """get_user_info returns a not_found error for unknown IDs."""
+    mock_find.return_value = None
+
+    async with Client(mcp_server) as client:
+        result = await client.call_tool(
+            "get_user_info", {"request": {"identifier": 9999}}
+        )
+
+    data = json.loads(result.content[0].text)
+    assert data["error_type"] == "not_found"
+
+
+@patch("superset.daos.user.UserDAO.find_by_id")
[email protected]
+async def test_get_user_info_includes_sensitive_when_allowed(mock_find, 
mcp_server):
+    """email and roles are included when caller has metadata access."""
+    user = create_mock_user(email="[email protected]", roles=["Alpha"])
+    mock_find.return_value = user
+
+    async with Client(mcp_server) as client:
+        result = await client.call_tool("get_user_info", {"request": 
{"identifier": 1}})
+
+    data = json.loads(result.content[0].text)
+    assert data["email"] == "[email protected]"
+    assert data["roles"] == ["Alpha"]
+
+
+@patch("superset.daos.user.UserDAO.find_by_id")
[email protected]
+async def test_get_user_info_redacts_sensitive_when_denied(mock_find, 
mcp_server):
+    """email and roles are redacted when caller lacks metadata access."""
+    user = create_mock_user(email="[email protected]", roles=["Alpha"])
+    mock_find.return_value = user
+
+    with patch.object(
+        get_user_info_module,
+        "user_can_view_data_model_metadata",
+        return_value=False,
+    ):
+        async with Client(mcp_server) as client:
+            result = await client.call_tool(
+                "get_user_info", {"request": {"identifier": 1}}
+            )
+
+    data = json.loads(result.content[0].text)
+    assert data["email"] is None
+    assert data["roles"] is None
+
+
+@patch("superset.daos.user.UserDAO.find_by_id")
[email protected]
+async def 
test_get_user_info_always_returns_basic_fields_without_metadata_access(
+    mock_find, mcp_server
+):
+    """Non-sensitive fields are always returned regardless of metadata 
access."""
+    user = create_mock_user(user_id=2, username="alice", first_name="Alice")
+    mock_find.return_value = user
+
+    with patch.object(
+        get_user_info_module,
+        "user_can_view_data_model_metadata",
+        return_value=False,
+    ):
+        async with Client(mcp_server) as client:
+            result = await client.call_tool(
+                "get_user_info", {"request": {"identifier": 2}}
+            )
+
+    data = json.loads(result.content[0].text)
+    assert data["id"] == 2
+    assert data["username"] == "alice"
+    assert data["first_name"] == "Alice"

Reply via email to