feat(mcp): Add tool annotations for MCP directory compliance (#38641)

This commit is contained in:
Amin Ghadersohi
2026-03-16 19:09:25 -07:00
committed by GitHub
parent ca403dc45d
commit 1c8224f4c6
22 changed files with 216 additions and 40 deletions
@@ -37,6 +37,13 @@ Usage:
from typing import Any, Callable, TypeVar
try:
from mcp.types import ToolAnnotations
except (
ImportError
): # MCP extras may not be installed in superset-core-only environments
ToolAnnotations = dict
# Type variable for decorated functions
F = TypeVar("F", bound=Callable[..., Any])
@@ -50,6 +57,7 @@ def tool(
protect: bool = True,
class_permission_name: str | None = None,
method_permission_name: str | None = None,
annotations: ToolAnnotations | None = None,
) -> Any: # Use Any to avoid mypy issues with dependency injection
"""
Decorator to register an MCP tool with optional authentication.
@@ -77,6 +85,8 @@ def tool(
permission checking via security_manager.can_access().
method_permission_name: FAB action name (e.g., "read", "write").
Defaults to "write" if tags includes "mutate", else "read".
annotations: MCP tool annotations (title, readOnlyHint, destructiveHint, etc.)
These hints help MCP clients understand tool behavior and safety.
Returns:
Decorator function that registers and wraps the tool, or the wrapped function
@@ -178,4 +188,5 @@ def prompt(
__all__ = [
"tool",
"prompt",
"ToolAnnotations",
]
+8
View File
@@ -25,6 +25,11 @@ that replaces the abstract functions in superset-core during initialization.
import logging
from typing import Any, Callable, Optional, TypeVar
try:
from mcp.types import ToolAnnotations
except ImportError:
ToolAnnotations = dict
from superset.extensions.context import get_current_extension_context
# Type variable for decorated functions
@@ -62,6 +67,7 @@ def create_tool_decorator(
protect: bool = True,
class_permission_name: Optional[str] = None,
method_permission_name: Optional[str] = None,
annotations: ToolAnnotations | None = None,
) -> Callable[[F], F] | F:
"""
Create the concrete MCP tool decorator implementation.
@@ -82,6 +88,7 @@ def create_tool_decorator(
(e.g., "Chart", "Dashboard", "SQLLab"). Enables permission checking.
method_permission_name: FAB action name (e.g., "read", "write").
Defaults to "write" if tags has "mutate", else "read".
annotations: MCP tool annotations (title, readOnlyHint, destructiveHint, etc.)
Returns:
Decorator that registers and wraps the tool with optional authentication,
@@ -130,6 +137,7 @@ def create_tool_decorator(
name=tool_name,
description=tool_description,
tags=tool_tags,
annotations=annotations,
)
mcp.add_tool(tool)
@@ -25,7 +25,7 @@ from typing import Any, Dict, List
from urllib.parse import parse_qs, urlparse
from fastmcp import Context
from superset_core.mcp.decorators import tool
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.commands.exceptions import CommandException
from superset.extensions import event_logger
@@ -119,7 +119,15 @@ def _compile_chart(
return CompileResult(success=False, error=str(exc))
@tool(tags=["mutate"], class_permission_name="Chart")
@tool(
tags=["mutate"],
class_permission_name="Chart",
annotations=ToolAnnotations(
title="Create chart",
readOnlyHint=False,
destructiveHint=False,
),
)
@parse_request(GenerateChartRequest)
async def generate_chart( # noqa: C901
request: GenerateChartRequest, ctx: Context
@@ -25,7 +25,7 @@ from typing import Any, Dict, List, TYPE_CHECKING
from fastmcp import Context
from flask import current_app
from superset_core.mcp.decorators import tool
from superset_core.mcp.decorators import tool, ToolAnnotations
if TYPE_CHECKING:
from superset.models.slice import Slice
@@ -74,7 +74,15 @@ def _get_cached_form_data(form_data_key: str) -> str | None:
return None
@tool(tags=["data"], class_permission_name="Chart")
@tool(
tags=["data"],
class_permission_name="Chart",
annotations=ToolAnnotations(
title="Get chart data",
readOnlyHint=True,
destructiveHint=False,
),
)
@parse_request(GetChartDataRequest)
async def get_chart_data( # noqa: C901
request: GetChartDataRequest, ctx: Context
@@ -23,7 +23,7 @@ import logging
from fastmcp import Context
from sqlalchemy.orm import subqueryload
from superset_core.mcp.decorators import tool
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.commands.exceptions import CommandException
from superset.commands.explore.form_data.parameters import CommandParameters
@@ -115,7 +115,15 @@ def _apply_unsaved_state_override(result: ChartInfo, form_data_key: str) -> None
)
@tool(tags=["discovery"], class_permission_name="Chart")
@tool(
tags=["discovery"],
class_permission_name="Chart",
annotations=ToolAnnotations(
title="Get chart info",
readOnlyHint=True,
destructiveHint=False,
),
)
@parse_request(GetChartInfoRequest)
async def get_chart_info(
request: GetChartInfoRequest, ctx: Context
@@ -23,7 +23,7 @@ import logging
from typing import Any, Dict, List, Protocol
from fastmcp import Context
from superset_core.mcp.decorators import tool
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.commands.exceptions import CommandException
from superset.exceptions import SupersetException
@@ -2177,7 +2177,15 @@ async def _get_chart_preview_internal( # noqa: C901
)
@tool(tags=["data"], class_permission_name="Chart")
@tool(
tags=["data"],
class_permission_name="Chart",
annotations=ToolAnnotations(
title="Get chart preview",
readOnlyHint=True,
destructiveHint=False,
),
)
@parse_request(GetChartPreviewRequest)
async def get_chart_preview(
request: GetChartPreviewRequest, ctx: Context
+10 -2
View File
@@ -23,7 +23,7 @@ import logging
from typing import cast, TYPE_CHECKING
from fastmcp import Context
from superset_core.mcp.decorators import tool
from superset_core.mcp.decorators import tool, ToolAnnotations
if TYPE_CHECKING:
from superset.models.slice import Slice
@@ -62,7 +62,15 @@ SORTABLE_CHART_COLUMNS = [
]
@tool(tags=["core"], class_permission_name="Chart")
@tool(
tags=["core"],
class_permission_name="Chart",
annotations=ToolAnnotations(
title="List charts",
readOnlyHint=True,
destructiveHint=False,
),
)
@parse_request(ListChartsRequest)
async def list_charts(request: ListChartsRequest, ctx: Context) -> ChartList:
"""List charts with filtering and search.
@@ -23,7 +23,7 @@ import logging
import time
from fastmcp import Context
from superset_core.mcp.decorators import tool
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.chart.chart_utils import (
@@ -45,7 +45,15 @@ from superset.utils import json
logger = logging.getLogger(__name__)
@tool(tags=["mutate"], class_permission_name="Chart")
@tool(
tags=["mutate"],
class_permission_name="Chart",
annotations=ToolAnnotations(
title="Update chart",
readOnlyHint=False,
destructiveHint=True,
),
)
@parse_request(UpdateChartRequest)
async def update_chart(
request: UpdateChartRequest, ctx: Context
@@ -24,7 +24,7 @@ import time
from typing import Any, Dict
from fastmcp import Context
from superset_core.mcp.decorators import tool
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.chart.chart_utils import (
@@ -44,7 +44,16 @@ from superset.mcp_service.utils.schema_utils import parse_request
logger = logging.getLogger(__name__)
@tool(tags=["mutate"], class_permission_name="Chart", method_permission_name="write")
@tool(
tags=["mutate"],
class_permission_name="Chart",
method_permission_name="write",
annotations=ToolAnnotations(
title="Update chart preview",
readOnlyHint=False,
destructiveHint=True,
),
)
@parse_request(UpdateChartPreviewRequest)
def update_chart_preview(
request: UpdateChartPreviewRequest, ctx: Context
@@ -26,7 +26,7 @@ import re
from typing import Any, Dict
from fastmcp import Context
from superset_core.mcp.decorators import tool
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.chart.schemas import serialize_chart_object
@@ -305,7 +305,15 @@ def _ensure_layout_structure(
layout["DASHBOARD_VERSION_KEY"] = "v2"
@tool(tags=["mutate"], class_permission_name="Dashboard")
@tool(
tags=["mutate"],
class_permission_name="Dashboard",
annotations=ToolAnnotations(
title="Add chart to dashboard",
readOnlyHint=False,
destructiveHint=False,
),
)
@parse_request(AddChartToDashboardRequest)
def add_chart_to_existing_dashboard(
request: AddChartToDashboardRequest, ctx: Context
@@ -25,7 +25,7 @@ import logging
from typing import Any, Dict, List
from fastmcp import Context
from superset_core.mcp.decorators import tool
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.chart.schemas import serialize_chart_object
@@ -178,7 +178,15 @@ def _generate_title_from_charts(chart_objects: List[Any]) -> str:
return title
@tool(tags=["mutate"], class_permission_name="Dashboard")
@tool(
tags=["mutate"],
class_permission_name="Dashboard",
annotations=ToolAnnotations(
title="Create dashboard",
readOnlyHint=False,
destructiveHint=False,
),
)
@parse_request(GenerateDashboardRequest)
def generate_dashboard(
request: GenerateDashboardRequest, ctx: Context
@@ -27,7 +27,7 @@ from datetime import datetime, timezone
from fastmcp import Context
from sqlalchemy.orm import subqueryload
from superset_core.mcp.decorators import tool
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.dashboards.permalink.exceptions import DashboardPermalinkGetFailedError
from superset.dashboards.permalink.types import DashboardPermalinkValue
@@ -59,7 +59,15 @@ def _get_permalink_state(permalink_key: str) -> DashboardPermalinkValue | None:
return None
@tool(tags=["discovery"], class_permission_name="Dashboard")
@tool(
tags=["discovery"],
class_permission_name="Dashboard",
annotations=ToolAnnotations(
title="Get dashboard info",
readOnlyHint=True,
destructiveHint=False,
),
)
@parse_request(GetDashboardInfoRequest)
async def get_dashboard_info(
request: GetDashboardInfoRequest, ctx: Context
@@ -26,7 +26,7 @@ import logging
from typing import TYPE_CHECKING
from fastmcp import Context
from superset_core.mcp.decorators import tool
from superset_core.mcp.decorators import tool, ToolAnnotations
if TYPE_CHECKING:
from superset.models.dashboard import Dashboard
@@ -63,7 +63,15 @@ SORTABLE_DASHBOARD_COLUMNS = [
]
@tool(tags=["core"], class_permission_name="Dashboard")
@tool(
tags=["core"],
class_permission_name="Dashboard",
annotations=ToolAnnotations(
title="List dashboards",
readOnlyHint=True,
destructiveHint=False,
),
)
@parse_request(ListDashboardsRequest)
async def list_dashboards(
request: ListDashboardsRequest, ctx: Context
@@ -27,7 +27,7 @@ from datetime import datetime, timezone
from fastmcp import Context
from sqlalchemy.orm import joinedload, subqueryload
from superset_core.mcp.decorators import tool
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.dataset.schemas import (
@@ -42,7 +42,15 @@ from superset.mcp_service.utils.schema_utils import parse_request
logger = logging.getLogger(__name__)
@tool(tags=["discovery"], class_permission_name="Dataset")
@tool(
tags=["discovery"],
class_permission_name="Dataset",
annotations=ToolAnnotations(
title="Get dataset info",
readOnlyHint=True,
destructiveHint=False,
),
)
@parse_request(GetDatasetInfoRequest)
async def get_dataset_info(
request: GetDatasetInfoRequest, ctx: Context
@@ -26,7 +26,7 @@ import logging
from typing import TYPE_CHECKING
from fastmcp import Context
from superset_core.mcp.decorators import tool
from superset_core.mcp.decorators import tool, ToolAnnotations
if TYPE_CHECKING:
from superset.connectors.sqla.models import SqlaTable
@@ -61,7 +61,15 @@ SORTABLE_DATASET_COLUMNS = [
]
@tool(tags=["core"], class_permission_name="Dataset")
@tool(
tags=["core"],
class_permission_name="Dataset",
annotations=ToolAnnotations(
title="List datasets",
readOnlyHint=True,
destructiveHint=False,
),
)
@parse_request(ListDatasetsRequest)
async def list_datasets(request: ListDatasetsRequest, ctx: Context) -> DatasetList:
"""List datasets with filtering and search.
@@ -26,7 +26,7 @@ from typing import Any, Dict
from urllib.parse import parse_qs, urlparse
from fastmcp import Context
from superset_core.mcp.decorators import tool
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.chart.chart_utils import (
@@ -39,7 +39,15 @@ from superset.mcp_service.chart.schemas import (
from superset.mcp_service.utils.schema_utils import parse_request
@tool(tags=["explore"], class_permission_name="Explore")
@tool(
tags=["explore"],
class_permission_name="Explore",
annotations=ToolAnnotations(
title="Generate explore link",
readOnlyHint=False,
destructiveHint=False,
),
)
@parse_request(GenerateExploreLinkRequest)
async def generate_explore_link(
request: GenerateExploreLinkRequest, ctx: Context
@@ -28,7 +28,7 @@ import logging
from typing import Any
from fastmcp import Context
from superset_core.mcp.decorators import tool
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset_core.queries.types import (
CacheOptions,
QueryOptions,
@@ -55,6 +55,11 @@ logger = logging.getLogger(__name__)
tags=["mutate"],
class_permission_name="SQLLab",
method_permission_name="execute_sql_query",
annotations=ToolAnnotations(
title="Execute SQL query",
readOnlyHint=False,
destructiveHint=True,
),
)
@parse_request(ExecuteSqlRequest)
async def execute_sql(request: ExecuteSqlRequest, ctx: Context) -> ExecuteSqlResponse:
@@ -25,7 +25,7 @@ import logging
from urllib.parse import urlencode
from fastmcp import Context
from superset_core.mcp.decorators import tool
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.sql_lab.schemas import (
@@ -38,7 +38,16 @@ from superset.mcp_service.utils.url_utils import get_superset_base_url
logger = logging.getLogger(__name__)
@tool(tags=["explore"], class_permission_name="SQLLab", method_permission_name="read")
@tool(
tags=["explore"],
class_permission_name="SQLLab",
method_permission_name="read",
annotations=ToolAnnotations(
title="Open SQL Lab with context",
readOnlyHint=True,
destructiveHint=False,
),
)
@parse_request(OpenSqlLabRequest)
def open_sql_lab_with_context(
request: OpenSqlLabRequest, ctx: Context
@@ -29,7 +29,7 @@ import logging
from fastmcp import Context
from sqlalchemy.exc import SQLAlchemyError
from superset_core.mcp.decorators import tool
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetErrorException, SupersetSecurityException
@@ -43,7 +43,16 @@ from superset.mcp_service.utils.schema_utils import parse_request
logger = logging.getLogger(__name__)
@tool(tags=["mutate"])
@tool(
tags=["mutate"],
class_permission_name="SQLLab",
method_permission_name="write",
annotations=ToolAnnotations(
title="Save SQL query",
readOnlyHint=False,
destructiveHint=False,
),
)
@parse_request(SaveSqlQueryRequest)
async def save_sql_query(
request: SaveSqlQueryRequest, ctx: Context
@@ -129,7 +138,7 @@ async def save_sql_query(
except SQLAlchemyError as e:
from superset import db
db.session.rollback()
db.session.rollback() # pylint: disable=consider-using-transaction
await ctx.error(
"Failed to save SQL query: error=%s, database_id=%s"
% (str(e), request.database_id)
@@ -23,7 +23,7 @@ InstanceInfoCore for flexible, extensible metrics calculation.
import logging
from fastmcp import Context
from superset_core.mcp.decorators import tool
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.mcp_core import InstanceInfoCore
@@ -73,7 +73,14 @@ _instance_info_core = InstanceInfoCore(
)
@tool(tags=["core"])
@tool(
tags=["core"],
annotations=ToolAnnotations(
title="Get instance info",
readOnlyHint=True,
destructiveHint=False,
),
)
@parse_request(GetSupersetInstanceInfoRequest)
def get_instance_info(
request: GetSupersetInstanceInfoRequest, ctx: Context
@@ -27,7 +27,7 @@ import logging
from typing import Callable, Literal
from fastmcp import Context
from superset_core.mcp.decorators import tool
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.common.schema_discovery import (
@@ -121,7 +121,14 @@ _SCHEMA_CORE_FACTORIES: dict[
}
@tool(tags=["discovery"])
@tool(
tags=["discovery"],
annotations=ToolAnnotations(
title="Get schema",
readOnlyHint=True,
destructiveHint=False,
),
)
@parse_request(GetSchemaRequest)
async def get_schema(request: GetSchemaRequest, ctx: Context) -> GetSchemaResponse:
"""
@@ -23,7 +23,7 @@ import platform
import time
from flask import current_app
from superset_core.mcp.decorators import tool
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.extensions import event_logger
from superset.mcp_service.system.schemas import HealthCheckResponse
@@ -34,7 +34,14 @@ logger = logging.getLogger(__name__)
_start_time = time.monotonic()
@tool(tags=["core"])
@tool(
tags=["core"],
annotations=ToolAnnotations(
title="Health check",
readOnlyHint=True,
destructiveHint=False,
),
)
async def health_check() -> HealthCheckResponse:
"""
Simple health check tool for testing the MCP service.