You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by mi...@apache.org on 2026/05/20 22:49:07 UTC
(superset) branch mcp-users-roles-99978 created (now 38d28c330ec)
This is an automated email from the ASF dual-hosted git repository.
aminghadersohi pushed a change to branch mcp-users-roles-99978
in repository https://gitbox.apache.org/repos/asf/superset.git
at 38d28c330ec feat(mcp): add list and get tools for users and roles
This branch includes the following new commits:
new 38d28c330ec feat(mcp): add list and get tools for users and roles
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(superset) 01/01: feat(mcp): add list and get tools for users and roles
Posted by mi...@apache.org.
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 <am...@gmail.com>
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
+
+
+@pytest.fixture
+def mcp_server():
+ return mcp
+
+
+@pytest.fixture(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")
+@pytest.mark.asyncio
+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")
+@pytest.mark.asyncio
+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")
+@pytest.mark.asyncio
+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")
+@pytest.mark.asyncio
+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")
+@pytest.mark.asyncio
+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")
+@pytest.mark.asyncio
+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
+
+
+@pytest.mark.asyncio
+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")
+@pytest.mark.asyncio
+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")
+@pytest.mark.asyncio
+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")
+@pytest.mark.asyncio
+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 = "admin@example.com",
+ 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
+
+
+@pytest.fixture
+def mcp_server():
+ return mcp
+
+
+@pytest.fixture(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
+
+
+@pytest.fixture(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")
+@pytest.mark.asyncio
+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")
+@pytest.mark.asyncio
+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")
+@pytest.mark.asyncio
+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")
+@pytest.mark.asyncio
+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")
+@pytest.mark.asyncio
+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="admin@example.com", 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"] == "admin@example.com"
+ assert data["users"][0]["roles"] == ["Admin"]
+
+
+@patch("superset.daos.user.UserDAO.list")
+@pytest.mark.asyncio
+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="admin@example.com", 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")
+@pytest.mark.asyncio
+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
+
+
+@pytest.mark.asyncio
+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")
+@pytest.mark.asyncio
+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")
+@pytest.mark.asyncio
+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")
+@pytest.mark.asyncio
+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="alice@example.com", 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"] == "alice@example.com"
+ assert data["roles"] == ["Alpha"]
+
+
+@patch("superset.daos.user.UserDAO.find_by_id")
+@pytest.mark.asyncio
+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="alice@example.com", 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")
+@pytest.mark.asyncio
+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"