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.
This commit is contained in:
Amin Ghadersohi
2026-05-20 22:48:43 +00:00
parent 68c145adc3
commit 38d28c330e
18 changed files with 1752 additions and 0 deletions

View File

@@ -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:

View File

@@ -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.

View File

@@ -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."""

View File

@@ -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),
)

View File

@@ -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",
]

View File

@@ -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),
)

View File

@@ -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

View File

@@ -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.

View File

@@ -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),
)

View File

@@ -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",
]

View File

@@ -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),
)

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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"

View File

@@ -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.

View File

@@ -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.

View File

@@ -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"