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/08/27 21:36:37 UTC
(superset) branch master updated: fix(mcp): add open-world tool annotations (#43529)
This is an automated email from the ASF dual-hosted git repository.
aminghadersohi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 478f612f258 fix(mcp): add open-world tool annotations (#43529)
478f612f258 is described below
commit 478f612f25890cf643b2941ac928de9b611194a0
Author: Amin Ghadersohi <am...@gmail.com>
AuthorDate: Thu Aug 27 17:36:30 2026 -0400
fix(mcp): add open-world tool annotations (#43529)
---
superset/mcp_service/CLAUDE.md | 13 +++++--
.../tool/get_annotation_layer_info.py | 1 +
.../tool/get_layer_annotation_info.py | 1 +
.../tool/list_annotation_layers.py | 1 +
.../tool/list_layer_annotations.py | 1 +
superset/mcp_service/chart/tool/delete_chart.py | 2 ++
superset/mcp_service/chart/tool/generate_chart.py | 2 ++
superset/mcp_service/chart/tool/get_chart_data.py | 1 +
superset/mcp_service/chart/tool/get_chart_info.py | 1 +
.../mcp_service/chart/tool/get_chart_preview.py | 1 +
superset/mcp_service/chart/tool/get_chart_sql.py | 1 +
.../chart/tool/get_chart_type_schema.py | 1 +
superset/mcp_service/chart/tool/list_charts.py | 1 +
superset/mcp_service/chart/tool/restore_chart.py | 2 ++
superset/mcp_service/chart/tool/update_chart.py | 2 ++
.../mcp_service/chart/tool/update_chart_preview.py | 2 ++
.../tool/add_chart_to_existing_dashboard.py | 2 ++
.../mcp_service/dashboard/tool/delete_dashboard.py | 2 ++
.../dashboard/tool/duplicate_dashboard.py | 2 ++
.../dashboard/tool/generate_dashboard.py | 2 ++
.../dashboard/tool/get_dashboard_datasets.py | 1 +
.../dashboard/tool/get_dashboard_info.py | 1 +
.../dashboard/tool/get_dashboard_layout.py | 1 +
.../mcp_service/dashboard/tool/list_dashboards.py | 1 +
.../tool/manage_dashboard_certification.py | 2 ++
.../dashboard/tool/manage_dashboard_owners.py | 2 ++
.../dashboard/tool/manage_dashboard_roles.py | 2 ++
.../dashboard/tool/manage_native_filters.py | 2 ++
.../dashboard/tool/remove_chart_from_dashboard.py | 2 ++
.../dashboard/tool/restore_dashboard.py | 2 ++
.../mcp_service/dashboard/tool/update_dashboard.py | 2 ++
.../mcp_service/database/tool/get_database_info.py | 1 +
.../mcp_service/database/tool/list_databases.py | 1 +
.../mcp_service/dataset/tool/create_dataset.py | 2 ++
.../dataset/tool/create_virtual_dataset.py | 2 ++
.../mcp_service/dataset/tool/get_dataset_info.py | 1 +
superset/mcp_service/dataset/tool/list_datasets.py | 1 +
superset/mcp_service/dataset/tool/query_dataset.py | 1 +
.../dataset/tool/update_dataset_metric.py | 2 ++
.../explore/tool/generate_explore_link.py | 2 ++
superset/mcp_service/query/tool/get_query_info.py | 1 +
superset/mcp_service/query/tool/list_queries.py | 1 +
.../mcp_service/report/tool/get_report_info.py | 1 +
superset/mcp_service/report/tool/list_reports.py | 1 +
.../mcp_service/rls/tool/get_rls_filter_info.py | 1 +
superset/mcp_service/rls/tool/list_rls_filters.py | 1 +
superset/mcp_service/role/tool/get_role_info.py | 1 +
superset/mcp_service/role/tool/list_roles.py | 1 +
.../saved_query/tool/get_saved_query_info.py | 1 +
.../saved_query/tool/list_saved_queries.py | 1 +
.../tool/get_compatible_dimensions.py | 1 +
.../semantic_layer/tool/get_compatible_metrics.py | 1 +
.../mcp_service/semantic_layer/tool/get_table.py | 1 +
.../semantic_layer/tool/list_metrics.py | 1 +
superset/mcp_service/sql_lab/tool/execute_sql.py | 2 ++
.../sql_lab/tool/open_sql_lab_with_context.py | 1 +
.../mcp_service/sql_lab/tool/save_sql_query.py | 2 ++
superset/mcp_service/system/tool/find_users.py | 1 +
.../mcp_service/system/tool/generate_bug_report.py | 1 +
.../mcp_service/system/tool/get_instance_info.py | 1 +
superset/mcp_service/system/tool/get_schema.py | 1 +
superset/mcp_service/system/tool/health_check.py | 1 +
superset/mcp_service/tag/tool/get_tag_info.py | 1 +
superset/mcp_service/tag/tool/list_tags.py | 1 +
superset/mcp_service/task/tool/get_task_info.py | 1 +
superset/mcp_service/task/tool/list_tasks.py | 1 +
superset/mcp_service/theme/tool/create_theme.py | 2 ++
superset/mcp_service/theme/tool/get_theme_info.py | 1 +
superset/mcp_service/theme/tool/list_themes.py | 1 +
superset/mcp_service/user/tool/get_user_info.py | 1 +
superset/mcp_service/user/tool/list_users.py | 1 +
tests/unit_tests/mcp_service/test_mcp_e2e_smoke.py | 12 +++++++
.../mcp_service/test_mcp_tool_registration.py | 42 ++++++++++++++++++++++
73 files changed, 157 insertions(+), 3 deletions(-)
diff --git a/superset/mcp_service/CLAUDE.md b/superset/mcp_service/CLAUDE.md
index 6f79a8c6802..5e092ab1356 100644
--- a/superset/mcp_service/CLAUDE.md
+++ b/superset/mcp_service/CLAUDE.md
@@ -119,6 +119,7 @@ from superset.extensions import event_logger
title="My new tool",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def my_new_tool(request: MyRequest, ctx: Context) -> MyResponse:
@@ -139,6 +140,8 @@ async def my_new_tool(request: MyRequest, ctx: Context) -> MyResponse:
title="Create something",
readOnlyHint=False,
destructiveHint=False,
+ idempotentHint=False,
+ openWorldHint=False,
),
)
async def create_something(request: CreateRequest, ctx: Context) -> CreateResponse:
@@ -210,8 +213,10 @@ The `@tool` decorator from `superset_core.mcp.decorators` accepts:
```python
annotations=ToolAnnotations(
title="Human-readable title",
- readOnlyHint=True, # Whether tool only reads data
- destructiveHint=False, # Whether tool has destructive side effects
+ readOnlyHint=False, # Whether tool only reads data
+ destructiveHint=False, # Whether tool has destructive side effects
+ idempotentHint=False, # Whether repeated mutating calls have additional effects
+ openWorldHint=False, # Whether tool interacts with external entities
)
```
@@ -572,7 +577,9 @@ async def test_my_tool_success(mcp_server):
### 4. Missing ToolAnnotations
**Problem**: Tool lacks MCP directory compliance metadata.
-**Solution**: Always include `annotations=ToolAnnotations(title=..., readOnlyHint=..., destructiveHint=...)`.
+**Solution**: Always include `title`, `readOnlyHint`, `destructiveHint`, and
+`openWorldHint`. Mutating tools (`readOnlyHint=False`) must also include
+`idempotentHint`.
### 5. Using `Optional` Instead of Union Syntax
**Problem**: Old-style `Optional[T]` is not Python 3.10+ style.
diff --git a/superset/mcp_service/annotation_layer/tool/get_annotation_layer_info.py b/superset/mcp_service/annotation_layer/tool/get_annotation_layer_info.py
index ad19260a89e..4d0502df98a 100644
--- a/superset/mcp_service/annotation_layer/tool/get_annotation_layer_info.py
+++ b/superset/mcp_service/annotation_layer/tool/get_annotation_layer_info.py
@@ -42,6 +42,7 @@ logger = logging.getLogger(__name__)
title="Get annotation layer info",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def get_annotation_layer_info(
diff --git a/superset/mcp_service/annotation_layer/tool/get_layer_annotation_info.py b/superset/mcp_service/annotation_layer/tool/get_layer_annotation_info.py
index 43ba7648e03..1abbf201143 100644
--- a/superset/mcp_service/annotation_layer/tool/get_layer_annotation_info.py
+++ b/superset/mcp_service/annotation_layer/tool/get_layer_annotation_info.py
@@ -41,6 +41,7 @@ logger = logging.getLogger(__name__)
title="Get annotation info",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def get_layer_annotation_info(
diff --git a/superset/mcp_service/annotation_layer/tool/list_annotation_layers.py b/superset/mcp_service/annotation_layer/tool/list_annotation_layers.py
index 1bd3050fb3d..266e90688ec 100644
--- a/superset/mcp_service/annotation_layer/tool/list_annotation_layers.py
+++ b/superset/mcp_service/annotation_layer/tool/list_annotation_layers.py
@@ -49,6 +49,7 @@ _SORTABLE_LAYER_COLUMNS = ["id", "name", "changed_on", "created_on"]
title="List annotation layers",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def list_annotation_layers(
diff --git a/superset/mcp_service/annotation_layer/tool/list_layer_annotations.py b/superset/mcp_service/annotation_layer/tool/list_layer_annotations.py
index c355c76a783..469c03daa15 100644
--- a/superset/mcp_service/annotation_layer/tool/list_layer_annotations.py
+++ b/superset/mcp_service/annotation_layer/tool/list_layer_annotations.py
@@ -56,6 +56,7 @@ _SORTABLE_ANNOTATION_COLUMNS = ["id", "short_descr", "start_dttm", "end_dttm"]
title="List annotations in a layer",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def list_layer_annotations(
diff --git a/superset/mcp_service/chart/tool/delete_chart.py b/superset/mcp_service/chart/tool/delete_chart.py
index df19de1012e..38692468d75 100644
--- a/superset/mcp_service/chart/tool/delete_chart.py
+++ b/superset/mcp_service/chart/tool/delete_chart.py
@@ -69,6 +69,8 @@ def _routes_to_soft_delete() -> bool:
title="Delete chart",
readOnlyHint=False,
destructiveHint=True,
+ idempotentHint=False,
+ openWorldHint=False,
),
)
async def delete_chart(
diff --git a/superset/mcp_service/chart/tool/generate_chart.py b/superset/mcp_service/chart/tool/generate_chart.py
index 5ff5da726a4..a9b063a7f93 100644
--- a/superset/mcp_service/chart/tool/generate_chart.py
+++ b/superset/mcp_service/chart/tool/generate_chart.py
@@ -71,6 +71,8 @@ __all__ = ["CompileResult", "_compile_chart", "validate_and_compile", "generate_
title="Create chart",
readOnlyHint=False,
destructiveHint=False,
+ idempotentHint=False,
+ openWorldHint=False,
),
)
async def generate_chart( # noqa: C901
diff --git a/superset/mcp_service/chart/tool/get_chart_data.py b/superset/mcp_service/chart/tool/get_chart_data.py
index a80250b7634..fade02b8d8f 100644
--- a/superset/mcp_service/chart/tool/get_chart_data.py
+++ b/superset/mcp_service/chart/tool/get_chart_data.py
@@ -316,6 +316,7 @@ def _build_query_results(
title="Get chart data",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def get_chart_data( # noqa: C901
diff --git a/superset/mcp_service/chart/tool/get_chart_info.py b/superset/mcp_service/chart/tool/get_chart_info.py
index eb7deba390b..aae922334c8 100644
--- a/superset/mcp_service/chart/tool/get_chart_info.py
+++ b/superset/mcp_service/chart/tool/get_chart_info.py
@@ -201,6 +201,7 @@ def _apply_unsaved_state_override(result: ChartInfo, form_data_key: str) -> None
title="Get chart info",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def get_chart_info(
diff --git a/superset/mcp_service/chart/tool/get_chart_preview.py b/superset/mcp_service/chart/tool/get_chart_preview.py
index 64dda21f79a..37dfb5b27b7 100644
--- a/superset/mcp_service/chart/tool/get_chart_preview.py
+++ b/superset/mcp_service/chart/tool/get_chart_preview.py
@@ -1399,6 +1399,7 @@ async def _get_chart_preview_internal( # noqa: C901
title="Get chart preview",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def get_chart_preview(
diff --git a/superset/mcp_service/chart/tool/get_chart_sql.py b/superset/mcp_service/chart/tool/get_chart_sql.py
index 6223f8d49f1..3914e2672ce 100644
--- a/superset/mcp_service/chart/tool/get_chart_sql.py
+++ b/superset/mcp_service/chart/tool/get_chart_sql.py
@@ -393,6 +393,7 @@ def _extract_sql_from_result(
title="Get chart SQL",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def get_chart_sql(
diff --git a/superset/mcp_service/chart/tool/get_chart_type_schema.py b/superset/mcp_service/chart/tool/get_chart_type_schema.py
index 1823b2542e6..57caa19959a 100644
--- a/superset/mcp_service/chart/tool/get_chart_type_schema.py
+++ b/superset/mcp_service/chart/tool/get_chart_type_schema.py
@@ -228,6 +228,7 @@ def _get_chart_type_schema_impl(
title="Get chart type schema",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
def get_chart_type_schema(
diff --git a/superset/mcp_service/chart/tool/list_charts.py b/superset/mcp_service/chart/tool/list_charts.py
index f066192c3c2..09185249ba9 100644
--- a/superset/mcp_service/chart/tool/list_charts.py
+++ b/superset/mcp_service/chart/tool/list_charts.py
@@ -71,6 +71,7 @@ _DEFAULT_LIST_CHARTS_REQUEST = ListChartsRequest()
title="List charts",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def list_charts(
diff --git a/superset/mcp_service/chart/tool/restore_chart.py b/superset/mcp_service/chart/tool/restore_chart.py
index 221ec027e52..f935f753fd6 100644
--- a/superset/mcp_service/chart/tool/restore_chart.py
+++ b/superset/mcp_service/chart/tool/restore_chart.py
@@ -76,6 +76,8 @@ def _rollback() -> None:
title="Restore chart",
readOnlyHint=False,
destructiveHint=False,
+ idempotentHint=False,
+ openWorldHint=False,
),
)
async def restore_chart(
diff --git a/superset/mcp_service/chart/tool/update_chart.py b/superset/mcp_service/chart/tool/update_chart.py
index fd22b057a7e..00c9ea8d87e 100644
--- a/superset/mcp_service/chart/tool/update_chart.py
+++ b/superset/mcp_service/chart/tool/update_chart.py
@@ -476,6 +476,8 @@ def _create_preview_url(
title="Update chart",
readOnlyHint=False,
destructiveHint=True,
+ idempotentHint=False,
+ openWorldHint=False,
),
)
async def update_chart( # noqa: C901
diff --git a/superset/mcp_service/chart/tool/update_chart_preview.py b/superset/mcp_service/chart/tool/update_chart_preview.py
index 9ba3b59e224..b2910567add 100644
--- a/superset/mcp_service/chart/tool/update_chart_preview.py
+++ b/superset/mcp_service/chart/tool/update_chart_preview.py
@@ -157,6 +157,8 @@ def _preserve_previous_adhoc_filters(
title="Update chart preview",
readOnlyHint=False,
destructiveHint=False,
+ idempotentHint=False,
+ openWorldHint=False,
),
)
def update_chart_preview( # noqa: C901
diff --git a/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py b/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py
index 107b81e7d15..e68ca63589f 100644
--- a/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py
+++ b/superset/mcp_service/dashboard/tool/add_chart_to_existing_dashboard.py
@@ -427,6 +427,8 @@ def _find_and_authorize_dashboard(
title="Add chart to dashboard",
readOnlyHint=False,
destructiveHint=False,
+ idempotentHint=False,
+ openWorldHint=False,
),
)
def add_chart_to_existing_dashboard( # noqa: C901 — complexity is structural (layout traversal + multi-step authorization), not accidental
diff --git a/superset/mcp_service/dashboard/tool/delete_dashboard.py b/superset/mcp_service/dashboard/tool/delete_dashboard.py
index 182250d88c3..b078ebe7ffa 100644
--- a/superset/mcp_service/dashboard/tool/delete_dashboard.py
+++ b/superset/mcp_service/dashboard/tool/delete_dashboard.py
@@ -93,6 +93,8 @@ def _routes_to_soft_delete() -> bool:
title="Delete dashboard",
readOnlyHint=False,
destructiveHint=True,
+ idempotentHint=False,
+ openWorldHint=False,
),
)
async def delete_dashboard(
diff --git a/superset/mcp_service/dashboard/tool/duplicate_dashboard.py b/superset/mcp_service/dashboard/tool/duplicate_dashboard.py
index 145901c4c45..eb3bea8b9a8 100644
--- a/superset/mcp_service/dashboard/tool/duplicate_dashboard.py
+++ b/superset/mcp_service/dashboard/tool/duplicate_dashboard.py
@@ -276,6 +276,8 @@ async def _resolve_source(
title="Duplicate dashboard",
readOnlyHint=False,
destructiveHint=False,
+ idempotentHint=False,
+ openWorldHint=False,
),
)
async def duplicate_dashboard(
diff --git a/superset/mcp_service/dashboard/tool/generate_dashboard.py b/superset/mcp_service/dashboard/tool/generate_dashboard.py
index 71319199279..d55b70affc3 100644
--- a/superset/mcp_service/dashboard/tool/generate_dashboard.py
+++ b/superset/mcp_service/dashboard/tool/generate_dashboard.py
@@ -191,6 +191,8 @@ def _generate_title_from_charts(chart_objects: List[Any]) -> str:
title="Create dashboard",
readOnlyHint=False,
destructiveHint=False,
+ idempotentHint=False,
+ openWorldHint=False,
),
)
def generate_dashboard( # noqa: C901
diff --git a/superset/mcp_service/dashboard/tool/get_dashboard_datasets.py b/superset/mcp_service/dashboard/tool/get_dashboard_datasets.py
index 293b8b683e7..7741e8e7455 100644
--- a/superset/mcp_service/dashboard/tool/get_dashboard_datasets.py
+++ b/superset/mcp_service/dashboard/tool/get_dashboard_datasets.py
@@ -54,6 +54,7 @@ logger = logging.getLogger(__name__)
title="Get dashboard datasets",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
@requires_data_model_metadata_access
diff --git a/superset/mcp_service/dashboard/tool/get_dashboard_info.py b/superset/mcp_service/dashboard/tool/get_dashboard_info.py
index 2cedcbe5d81..a53718cf18e 100644
--- a/superset/mcp_service/dashboard/tool/get_dashboard_info.py
+++ b/superset/mcp_service/dashboard/tool/get_dashboard_info.py
@@ -96,6 +96,7 @@ def _lookup_dashboard(
title="Get dashboard info",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def get_dashboard_info(
diff --git a/superset/mcp_service/dashboard/tool/get_dashboard_layout.py b/superset/mcp_service/dashboard/tool/get_dashboard_layout.py
index 60b855500f2..facfcad3a76 100644
--- a/superset/mcp_service/dashboard/tool/get_dashboard_layout.py
+++ b/superset/mcp_service/dashboard/tool/get_dashboard_layout.py
@@ -53,6 +53,7 @@ logger = logging.getLogger(__name__)
title="Get dashboard layout",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def get_dashboard_layout(
diff --git a/superset/mcp_service/dashboard/tool/list_dashboards.py b/superset/mcp_service/dashboard/tool/list_dashboards.py
index 84426f54c30..b9c2a2b31e9 100644
--- a/superset/mcp_service/dashboard/tool/list_dashboards.py
+++ b/superset/mcp_service/dashboard/tool/list_dashboards.py
@@ -66,6 +66,7 @@ _DEFAULT_LIST_DASHBOARDS_REQUEST = ListDashboardsRequest()
title="List dashboards",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def list_dashboards(
diff --git a/superset/mcp_service/dashboard/tool/manage_dashboard_certification.py b/superset/mcp_service/dashboard/tool/manage_dashboard_certification.py
index 1737b9eee81..0c7ebedc6bd 100644
--- a/superset/mcp_service/dashboard/tool/manage_dashboard_certification.py
+++ b/superset/mcp_service/dashboard/tool/manage_dashboard_certification.py
@@ -51,6 +51,8 @@ logger: logging.Logger = logging.getLogger(__name__)
title="Manage dashboard certification",
readOnlyHint=False,
destructiveHint=False,
+ idempotentHint=False,
+ openWorldHint=False,
),
)
def manage_dashboard_certification(
diff --git a/superset/mcp_service/dashboard/tool/manage_dashboard_owners.py b/superset/mcp_service/dashboard/tool/manage_dashboard_owners.py
index c231da7a9de..e304105c53b 100644
--- a/superset/mcp_service/dashboard/tool/manage_dashboard_owners.py
+++ b/superset/mcp_service/dashboard/tool/manage_dashboard_owners.py
@@ -248,6 +248,8 @@ def _build_owner_warnings(
title="Manage dashboard owners",
readOnlyHint=False,
destructiveHint=True,
+ idempotentHint=False,
+ openWorldHint=False,
),
)
def manage_dashboard_owners(
diff --git a/superset/mcp_service/dashboard/tool/manage_dashboard_roles.py b/superset/mcp_service/dashboard/tool/manage_dashboard_roles.py
index 6d565fa82bf..0ae010c7497 100644
--- a/superset/mcp_service/dashboard/tool/manage_dashboard_roles.py
+++ b/superset/mcp_service/dashboard/tool/manage_dashboard_roles.py
@@ -160,6 +160,8 @@ def _resolve_role_subjects(
title="Manage dashboard access roles",
readOnlyHint=False,
destructiveHint=True,
+ idempotentHint=False,
+ openWorldHint=False,
),
)
def manage_dashboard_roles(
diff --git a/superset/mcp_service/dashboard/tool/manage_native_filters.py b/superset/mcp_service/dashboard/tool/manage_native_filters.py
index 8a7c01a8d43..709f5d30610 100644
--- a/superset/mcp_service/dashboard/tool/manage_native_filters.py
+++ b/superset/mcp_service/dashboard/tool/manage_native_filters.py
@@ -376,6 +376,8 @@ def _build_native_filters_payload( # noqa: C901
title="Manage dashboard native filters",
readOnlyHint=False,
destructiveHint=True,
+ idempotentHint=False,
+ openWorldHint=False,
),
)
def manage_native_filters(
diff --git a/superset/mcp_service/dashboard/tool/remove_chart_from_dashboard.py b/superset/mcp_service/dashboard/tool/remove_chart_from_dashboard.py
index 1ad1f5a2c60..863ef45cfa1 100644
--- a/superset/mcp_service/dashboard/tool/remove_chart_from_dashboard.py
+++ b/superset/mcp_service/dashboard/tool/remove_chart_from_dashboard.py
@@ -278,6 +278,8 @@ def _find_and_authorize_dashboard(
title="Remove chart from dashboard",
readOnlyHint=False,
destructiveHint=True,
+ idempotentHint=False,
+ openWorldHint=False,
),
)
def remove_chart_from_dashboard( # noqa: C901 — complexity is structural (layout traversal + multi-step authorization), not accidental
diff --git a/superset/mcp_service/dashboard/tool/restore_dashboard.py b/superset/mcp_service/dashboard/tool/restore_dashboard.py
index d28b0267a9d..4fabbfc7c9b 100644
--- a/superset/mcp_service/dashboard/tool/restore_dashboard.py
+++ b/superset/mcp_service/dashboard/tool/restore_dashboard.py
@@ -78,6 +78,8 @@ def _rollback() -> None:
title="Restore dashboard",
readOnlyHint=False,
destructiveHint=False,
+ idempotentHint=False,
+ openWorldHint=False,
),
)
async def restore_dashboard(
diff --git a/superset/mcp_service/dashboard/tool/update_dashboard.py b/superset/mcp_service/dashboard/tool/update_dashboard.py
index b468573dc83..2045936edbd 100644
--- a/superset/mcp_service/dashboard/tool/update_dashboard.py
+++ b/superset/mcp_service/dashboard/tool/update_dashboard.py
@@ -291,6 +291,8 @@ def _validate_update_request(
title="Update dashboard layout/theme/CSS/metadata",
readOnlyHint=False,
destructiveHint=True,
+ idempotentHint=False,
+ openWorldHint=False,
),
)
async def update_dashboard(
diff --git a/superset/mcp_service/database/tool/get_database_info.py b/superset/mcp_service/database/tool/get_database_info.py
index c91b4306ad8..c23f4dab6e8 100644
--- a/superset/mcp_service/database/tool/get_database_info.py
+++ b/superset/mcp_service/database/tool/get_database_info.py
@@ -52,6 +52,7 @@ logger = logging.getLogger(__name__)
title="Get database info",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
@requires_data_model_metadata_access
diff --git a/superset/mcp_service/database/tool/list_databases.py b/superset/mcp_service/database/tool/list_databases.py
index b03949146ed..fc2fca6d908 100644
--- a/superset/mcp_service/database/tool/list_databases.py
+++ b/superset/mcp_service/database/tool/list_databases.py
@@ -59,6 +59,7 @@ _DEFAULT_LIST_DATABASES_REQUEST = ListDatabasesRequest()
title="List databases",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
@requires_data_model_metadata_access
diff --git a/superset/mcp_service/dataset/tool/create_dataset.py b/superset/mcp_service/dataset/tool/create_dataset.py
index 16dcd682579..9a3a903973a 100644
--- a/superset/mcp_service/dataset/tool/create_dataset.py
+++ b/superset/mcp_service/dataset/tool/create_dataset.py
@@ -119,6 +119,8 @@ def _build_dataset_properties(
title="Register physical table as dataset",
readOnlyHint=False,
destructiveHint=False,
+ idempotentHint=False,
+ openWorldHint=False,
),
)
async def create_dataset(
diff --git a/superset/mcp_service/dataset/tool/create_virtual_dataset.py b/superset/mcp_service/dataset/tool/create_virtual_dataset.py
index f16e31b0f00..f231a019258 100644
--- a/superset/mcp_service/dataset/tool/create_virtual_dataset.py
+++ b/superset/mcp_service/dataset/tool/create_virtual_dataset.py
@@ -91,6 +91,8 @@ def _update_virtual_dataset(dataset_id: int, update_props: dict[str, Any]) -> An
title="Create virtual dataset from SQL",
readOnlyHint=False,
destructiveHint=False,
+ idempotentHint=False,
+ openWorldHint=False,
),
)
async def create_virtual_dataset( # noqa: C901
diff --git a/superset/mcp_service/dataset/tool/get_dataset_info.py b/superset/mcp_service/dataset/tool/get_dataset_info.py
index f5cde391f26..d9ee6ba4814 100644
--- a/superset/mcp_service/dataset/tool/get_dataset_info.py
+++ b/superset/mcp_service/dataset/tool/get_dataset_info.py
@@ -54,6 +54,7 @@ logger = logging.getLogger(__name__)
title="Get dataset info",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
@requires_data_model_metadata_access
diff --git a/superset/mcp_service/dataset/tool/list_datasets.py b/superset/mcp_service/dataset/tool/list_datasets.py
index 7422823b463..b0e3a73f83f 100644
--- a/superset/mcp_service/dataset/tool/list_datasets.py
+++ b/superset/mcp_service/dataset/tool/list_datasets.py
@@ -76,6 +76,7 @@ _DEFAULT_LIST_DATASETS_REQUEST = ListDatasetsRequest()
title="List datasets",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
@requires_data_model_metadata_access
diff --git a/superset/mcp_service/dataset/tool/query_dataset.py b/superset/mcp_service/dataset/tool/query_dataset.py
index d306242696b..eb843a07e24 100644
--- a/superset/mcp_service/dataset/tool/query_dataset.py
+++ b/superset/mcp_service/dataset/tool/query_dataset.py
@@ -70,6 +70,7 @@ _NO_SAVED_METRICS_HINT = (
title="Query dataset",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
@requires_data_model_metadata_access
diff --git a/superset/mcp_service/dataset/tool/update_dataset_metric.py b/superset/mcp_service/dataset/tool/update_dataset_metric.py
index 3b6ce766898..09254fd4062 100644
--- a/superset/mcp_service/dataset/tool/update_dataset_metric.py
+++ b/superset/mcp_service/dataset/tool/update_dataset_metric.py
@@ -102,6 +102,8 @@ def _serialize_metric(metric: Any) -> DatasetMetricDetail:
# Overwrites an existing metric's definition and affects every chart
# that uses it — non-additive, like update_chart.
destructiveHint=True,
+ idempotentHint=False,
+ openWorldHint=False,
),
)
async def update_dataset_metric( # noqa: C901
diff --git a/superset/mcp_service/explore/tool/generate_explore_link.py b/superset/mcp_service/explore/tool/generate_explore_link.py
index 383a174c68c..178c95ea8ae 100644
--- a/superset/mcp_service/explore/tool/generate_explore_link.py
+++ b/superset/mcp_service/explore/tool/generate_explore_link.py
@@ -58,6 +58,8 @@ logger = logging.getLogger(__name__)
title="Generate explore link",
readOnlyHint=False,
destructiveHint=False,
+ idempotentHint=False,
+ openWorldHint=False,
),
)
async def generate_explore_link(
diff --git a/superset/mcp_service/query/tool/get_query_info.py b/superset/mcp_service/query/tool/get_query_info.py
index f26dac2349a..8b733328dee 100644
--- a/superset/mcp_service/query/tool/get_query_info.py
+++ b/superset/mcp_service/query/tool/get_query_info.py
@@ -47,6 +47,7 @@ logger = logging.getLogger(__name__)
title="Get query info",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def get_query_info(
diff --git a/superset/mcp_service/query/tool/list_queries.py b/superset/mcp_service/query/tool/list_queries.py
index 64a97e0cb45..caafd78fbc6 100644
--- a/superset/mcp_service/query/tool/list_queries.py
+++ b/superset/mcp_service/query/tool/list_queries.py
@@ -52,6 +52,7 @@ _DEFAULT_LIST_QUERIES_REQUEST = ListQueriesRequest()
title="List queries",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def list_queries(
diff --git a/superset/mcp_service/report/tool/get_report_info.py b/superset/mcp_service/report/tool/get_report_info.py
index e6ffef35b39..7ce3322cacc 100644
--- a/superset/mcp_service/report/tool/get_report_info.py
+++ b/superset/mcp_service/report/tool/get_report_info.py
@@ -44,6 +44,7 @@ logger = logging.getLogger(__name__)
title="Get report info",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def get_report_info(
diff --git a/superset/mcp_service/report/tool/list_reports.py b/superset/mcp_service/report/tool/list_reports.py
index 1f8e58c5f43..7f5e4ddfec9 100644
--- a/superset/mcp_service/report/tool/list_reports.py
+++ b/superset/mcp_service/report/tool/list_reports.py
@@ -58,6 +58,7 @@ _DEFAULT_LIST_REPORTS_REQUEST = ListReportsRequest()
title="List reports",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def list_reports(
diff --git a/superset/mcp_service/rls/tool/get_rls_filter_info.py b/superset/mcp_service/rls/tool/get_rls_filter_info.py
index 86a6ee718f7..1efa5ec5f49 100644
--- a/superset/mcp_service/rls/tool/get_rls_filter_info.py
+++ b/superset/mcp_service/rls/tool/get_rls_filter_info.py
@@ -44,6 +44,7 @@ logger = logging.getLogger(__name__)
title="Get RLS filter info",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def get_rls_filter_info(
diff --git a/superset/mcp_service/rls/tool/list_rls_filters.py b/superset/mcp_service/rls/tool/list_rls_filters.py
index 0dd972994f7..589787928a1 100644
--- a/superset/mcp_service/rls/tool/list_rls_filters.py
+++ b/superset/mcp_service/rls/tool/list_rls_filters.py
@@ -50,6 +50,7 @@ _DEFAULT_LIST_RLS_FILTERS_REQUEST = ListRlsFiltersRequest()
title="List RLS filters",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def list_rls_filters(
diff --git a/superset/mcp_service/role/tool/get_role_info.py b/superset/mcp_service/role/tool/get_role_info.py
index d962c0a9116..3f67d4ace7b 100644
--- a/superset/mcp_service/role/tool/get_role_info.py
+++ b/superset/mcp_service/role/tool/get_role_info.py
@@ -45,6 +45,7 @@ logger = logging.getLogger(__name__)
title="Get role info",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def get_role_info(
diff --git a/superset/mcp_service/role/tool/list_roles.py b/superset/mcp_service/role/tool/list_roles.py
index 4f7482ed09b..4ce66e7154c 100644
--- a/superset/mcp_service/role/tool/list_roles.py
+++ b/superset/mcp_service/role/tool/list_roles.py
@@ -53,6 +53,7 @@ _DEFAULT_LIST_ROLES_REQUEST = ListRolesRequest()
title="List roles",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def list_roles(
diff --git a/superset/mcp_service/saved_query/tool/get_saved_query_info.py b/superset/mcp_service/saved_query/tool/get_saved_query_info.py
index f1a4f19b3f1..5a4c9cd2773 100644
--- a/superset/mcp_service/saved_query/tool/get_saved_query_info.py
+++ b/superset/mcp_service/saved_query/tool/get_saved_query_info.py
@@ -47,6 +47,7 @@ logger = logging.getLogger(__name__)
title="Get saved query info",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def get_saved_query_info(
diff --git a/superset/mcp_service/saved_query/tool/list_saved_queries.py b/superset/mcp_service/saved_query/tool/list_saved_queries.py
index d4f09bb60fc..42b6a8ca279 100644
--- a/superset/mcp_service/saved_query/tool/list_saved_queries.py
+++ b/superset/mcp_service/saved_query/tool/list_saved_queries.py
@@ -52,6 +52,7 @@ _DEFAULT_LIST_SAVED_QUERIES_REQUEST = ListSavedQueriesRequest()
title="List saved queries",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def list_saved_queries(
diff --git a/superset/mcp_service/semantic_layer/tool/get_compatible_dimensions.py b/superset/mcp_service/semantic_layer/tool/get_compatible_dimensions.py
index 49149560db3..0b2b8bd28d9 100644
--- a/superset/mcp_service/semantic_layer/tool/get_compatible_dimensions.py
+++ b/superset/mcp_service/semantic_layer/tool/get_compatible_dimensions.py
@@ -49,6 +49,7 @@ logger = logging.getLogger(__name__)
title="Get compatible dimensions",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
@requires_data_model_metadata_access
diff --git a/superset/mcp_service/semantic_layer/tool/get_compatible_metrics.py b/superset/mcp_service/semantic_layer/tool/get_compatible_metrics.py
index 55117c2a5c3..87a36131fe8 100644
--- a/superset/mcp_service/semantic_layer/tool/get_compatible_metrics.py
+++ b/superset/mcp_service/semantic_layer/tool/get_compatible_metrics.py
@@ -49,6 +49,7 @@ logger = logging.getLogger(__name__)
title="Get compatible metrics",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
@requires_data_model_metadata_access
diff --git a/superset/mcp_service/semantic_layer/tool/get_table.py b/superset/mcp_service/semantic_layer/tool/get_table.py
index 7306e37e86e..39078b1116a 100644
--- a/superset/mcp_service/semantic_layer/tool/get_table.py
+++ b/superset/mcp_service/semantic_layer/tool/get_table.py
@@ -416,6 +416,7 @@ def _validate_datasource_selection(
title="Get table",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
@requires_data_model_metadata_access
diff --git a/superset/mcp_service/semantic_layer/tool/list_metrics.py b/superset/mcp_service/semantic_layer/tool/list_metrics.py
index 54dd1669893..1b060c27deb 100644
--- a/superset/mcp_service/semantic_layer/tool/list_metrics.py
+++ b/superset/mcp_service/semantic_layer/tool/list_metrics.py
@@ -236,6 +236,7 @@ async def _collect_external_metrics(
title="List metrics",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
@requires_data_model_metadata_access
diff --git a/superset/mcp_service/sql_lab/tool/execute_sql.py b/superset/mcp_service/sql_lab/tool/execute_sql.py
index 6ed79c87eaa..e79fbe8e930 100644
--- a/superset/mcp_service/sql_lab/tool/execute_sql.py
+++ b/superset/mcp_service/sql_lab/tool/execute_sql.py
@@ -114,6 +114,8 @@ async def _validate_non_destructive_sql(
title="Execute SQL query",
readOnlyHint=False,
destructiveHint=True,
+ idempotentHint=False,
+ openWorldHint=False,
),
)
async def execute_sql(request: ExecuteSqlRequest, ctx: Context) -> ExecuteSqlResponse:
diff --git a/superset/mcp_service/sql_lab/tool/open_sql_lab_with_context.py b/superset/mcp_service/sql_lab/tool/open_sql_lab_with_context.py
index 76add8f6a5d..78b5907df00 100644
--- a/superset/mcp_service/sql_lab/tool/open_sql_lab_with_context.py
+++ b/superset/mcp_service/sql_lab/tool/open_sql_lab_with_context.py
@@ -45,6 +45,7 @@ logger = logging.getLogger(__name__)
title="Open SQL Lab with context",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
def open_sql_lab_with_context(
diff --git a/superset/mcp_service/sql_lab/tool/save_sql_query.py b/superset/mcp_service/sql_lab/tool/save_sql_query.py
index 8126bf19efb..56a534164c7 100644
--- a/superset/mcp_service/sql_lab/tool/save_sql_query.py
+++ b/superset/mcp_service/sql_lab/tool/save_sql_query.py
@@ -48,6 +48,8 @@ logger = logging.getLogger(__name__)
title="Save SQL query",
readOnlyHint=False,
destructiveHint=False,
+ idempotentHint=False,
+ openWorldHint=False,
),
)
async def save_sql_query(
diff --git a/superset/mcp_service/system/tool/find_users.py b/superset/mcp_service/system/tool/find_users.py
index 6e46585a48b..e56c8136e78 100755
--- a/superset/mcp_service/system/tool/find_users.py
+++ b/superset/mcp_service/system/tool/find_users.py
@@ -40,6 +40,7 @@ logger = logging.getLogger(__name__)
title="Find users",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def find_users(request: FindUsersRequest, ctx: Context) -> FindUsersResponse:
diff --git a/superset/mcp_service/system/tool/generate_bug_report.py b/superset/mcp_service/system/tool/generate_bug_report.py
index 0a4cff79a94..baf3fa2a8a6 100644
--- a/superset/mcp_service/system/tool/generate_bug_report.py
+++ b/superset/mcp_service/system/tool/generate_bug_report.py
@@ -261,6 +261,7 @@ def _format_report(
title="Generate bug report",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def generate_bug_report(
diff --git a/superset/mcp_service/system/tool/get_instance_info.py b/superset/mcp_service/system/tool/get_instance_info.py
index 2aaf4692ebd..bf6ff243036 100644
--- a/superset/mcp_service/system/tool/get_instance_info.py
+++ b/superset/mcp_service/system/tool/get_instance_info.py
@@ -95,6 +95,7 @@ def _redact_data_model_metadata(result: InstanceInfo) -> InstanceInfo:
title="Get instance info",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
def get_instance_info(
diff --git a/superset/mcp_service/system/tool/get_schema.py b/superset/mcp_service/system/tool/get_schema.py
index ca3cff2ad83..d80b64478d8 100644
--- a/superset/mcp_service/system/tool/get_schema.py
+++ b/superset/mcp_service/system/tool/get_schema.py
@@ -201,6 +201,7 @@ _MODEL_TYPE_CLASS_PERMISSION: dict[ModelType, str] = {
title="Get schema",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def get_schema(
diff --git a/superset/mcp_service/system/tool/health_check.py b/superset/mcp_service/system/tool/health_check.py
index e41f673ac8b..35145038cd8 100644
--- a/superset/mcp_service/system/tool/health_check.py
+++ b/superset/mcp_service/system/tool/health_check.py
@@ -40,6 +40,7 @@ _start_time = time.monotonic()
title="Health check",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def health_check() -> HealthCheckResponse:
diff --git a/superset/mcp_service/tag/tool/get_tag_info.py b/superset/mcp_service/tag/tool/get_tag_info.py
index ab4cde6a929..547f52270de 100644
--- a/superset/mcp_service/tag/tool/get_tag_info.py
+++ b/superset/mcp_service/tag/tool/get_tag_info.py
@@ -47,6 +47,7 @@ logger = logging.getLogger(__name__)
title="Get tag info",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def get_tag_info(request: GetTagInfoRequest, ctx: Context) -> TagInfo | TagError:
diff --git a/superset/mcp_service/tag/tool/list_tags.py b/superset/mcp_service/tag/tool/list_tags.py
index f248dad6c3e..444d19482a3 100644
--- a/superset/mcp_service/tag/tool/list_tags.py
+++ b/superset/mcp_service/tag/tool/list_tags.py
@@ -63,6 +63,7 @@ _DEFAULT_LIST_TAGS_REQUEST = ListTagsRequest()
title="List tags",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def list_tags(
diff --git a/superset/mcp_service/task/tool/get_task_info.py b/superset/mcp_service/task/tool/get_task_info.py
index 6927eebeb07..a051bfe4833 100644
--- a/superset/mcp_service/task/tool/get_task_info.py
+++ b/superset/mcp_service/task/tool/get_task_info.py
@@ -43,6 +43,7 @@ logger = logging.getLogger(__name__)
title="Get task info",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def get_task_info(
diff --git a/superset/mcp_service/task/tool/list_tasks.py b/superset/mcp_service/task/tool/list_tasks.py
index 609dcf9becc..607e14b2281 100644
--- a/superset/mcp_service/task/tool/list_tasks.py
+++ b/superset/mcp_service/task/tool/list_tasks.py
@@ -49,6 +49,7 @@ _DEFAULT_LIST_TASKS_REQUEST = ListTasksRequest()
title="List tasks",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def list_tasks(
diff --git a/superset/mcp_service/theme/tool/create_theme.py b/superset/mcp_service/theme/tool/create_theme.py
index 4a32911e34d..25b41cc5734 100644
--- a/superset/mcp_service/theme/tool/create_theme.py
+++ b/superset/mcp_service/theme/tool/create_theme.py
@@ -47,6 +47,8 @@ logger = logging.getLogger(__name__)
title="Create theme",
readOnlyHint=False,
destructiveHint=False,
+ idempotentHint=False,
+ openWorldHint=False,
),
)
async def create_theme(
diff --git a/superset/mcp_service/theme/tool/get_theme_info.py b/superset/mcp_service/theme/tool/get_theme_info.py
index 9af4ca19c8f..b449f00b6a2 100644
--- a/superset/mcp_service/theme/tool/get_theme_info.py
+++ b/superset/mcp_service/theme/tool/get_theme_info.py
@@ -47,6 +47,7 @@ logger = logging.getLogger(__name__)
title="Get theme info",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def get_theme_info(
diff --git a/superset/mcp_service/theme/tool/list_themes.py b/superset/mcp_service/theme/tool/list_themes.py
index 362d5c7e740..5782ffd04e7 100644
--- a/superset/mcp_service/theme/tool/list_themes.py
+++ b/superset/mcp_service/theme/tool/list_themes.py
@@ -71,6 +71,7 @@ _DEFAULT_LIST_THEMES_REQUEST = ListThemesRequest()
title="List themes",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def list_themes(
diff --git a/superset/mcp_service/user/tool/get_user_info.py b/superset/mcp_service/user/tool/get_user_info.py
index 156db4c80b5..bedf2c517b4 100644
--- a/superset/mcp_service/user/tool/get_user_info.py
+++ b/superset/mcp_service/user/tool/get_user_info.py
@@ -46,6 +46,7 @@ logger = logging.getLogger(__name__)
title="Get user info",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def get_user_info(
diff --git a/superset/mcp_service/user/tool/list_users.py b/superset/mcp_service/user/tool/list_users.py
index f9d658a88b3..66466426092 100644
--- a/superset/mcp_service/user/tool/list_users.py
+++ b/superset/mcp_service/user/tool/list_users.py
@@ -54,6 +54,7 @@ _DEFAULT_LIST_USERS_REQUEST = ListUsersRequest()
title="List users",
readOnlyHint=True,
destructiveHint=False,
+ openWorldHint=False,
),
)
async def list_users(
diff --git a/tests/unit_tests/mcp_service/test_mcp_e2e_smoke.py b/tests/unit_tests/mcp_service/test_mcp_e2e_smoke.py
index a2930ed4032..dde4cb37be9 100644
--- a/tests/unit_tests/mcp_service/test_mcp_e2e_smoke.py
+++ b/tests/unit_tests/mcp_service/test_mcp_e2e_smoke.py
@@ -212,6 +212,18 @@ async def test_tools_list_over_real_asgi_transport() -> None:
assert len(tools) > 0
tool_names = {tool.name for tool in tools}
assert "health_check" in tool_names
+ assert not {
+ tool.name
+ for tool in tools
+ if tool.annotations is None or tool.annotations.openWorldHint is not False
+ }
+ assert not {
+ tool.name
+ for tool in tools
+ if tool.annotations is not None
+ and tool.annotations.readOnlyHint is False
+ and tool.annotations.idempotentHint is not False
+ }
@pytest.mark.asyncio
diff --git a/tests/unit_tests/mcp_service/test_mcp_tool_registration.py b/tests/unit_tests/mcp_service/test_mcp_tool_registration.py
index 3ebccf3f194..46db53a8e13 100644
--- a/tests/unit_tests/mcp_service/test_mcp_tool_registration.py
+++ b/tests/unit_tests/mcp_service/test_mcp_tool_registration.py
@@ -57,6 +57,48 @@ def test_mcp_app_imports_successfully():
assert "list_charts" in tool_names
+def test_all_registered_tools_have_complete_annotations():
+ """Every advertised tool declares all MCP safety annotations."""
+ required_fields = (
+ "title",
+ "readOnlyHint",
+ "destructiveHint",
+ "openWorldHint",
+ )
+ missing_by_tool = {}
+ open_world_tools = []
+ idempotent_mutating_tools = []
+
+ for registered_tool in _run(mcp.list_tools()):
+ annotations = registered_tool.annotations
+ missing = [
+ field
+ for field in required_fields
+ if annotations is None or getattr(annotations, field, None) is None
+ ]
+ if annotations is not None and annotations.readOnlyHint is False:
+ if annotations.idempotentHint is None:
+ missing.append("idempotentHint")
+ elif annotations.idempotentHint is not False:
+ idempotent_mutating_tools.append(registered_tool.name)
+ if missing:
+ missing_by_tool[registered_tool.name] = missing
+ elif annotations.openWorldHint is not False:
+ open_world_tools.append(registered_tool.name)
+
+ assert not missing_by_tool, (
+ f"Registered MCP tools have incomplete annotations: {missing_by_tool}"
+ )
+ assert not open_world_tools, (
+ "Tools must be explicitly closed-world (openWorldHint=False): "
+ f"{open_world_tools}"
+ )
+ assert not idempotent_mutating_tools, (
+ "Mutating tools must not claim idempotency (idempotentHint=False): "
+ f"{idempotent_mutating_tools}"
+ )
+
+
def test_mcp_prompts_registered():
"""Test that MCP prompts are registered."""
prompts = _run(mcp.list_prompts())