You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@superset.apache.org by "tien238lnd (via GitHub)" <gi...@apache.org> on 2026/09/16 07:48:32 UTC
[PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
tien238lnd opened a new pull request, #44337:
URL: https://github.com/apache/superset/pull/44337
### SUMMARY
The MCP server can create datasets but cannot delete them or change them: an agent that creates exploratory virtual datasets has no way to clean them up, and fixing a virtual dataset's SQL means creating a new dataset and repointing every chart. Charts and dashboards already have delete, restore and trash listing (#41472, #41842, #41855), and dataset soft-delete landed in #40130, but the MCP side of it was never added.
**`delete_dataset`** — delete by numeric ID or UUID through `DeleteDatasetCommand`, which enforces editorship. The response reports whether the row went to trash (`SOFT_DELETE`) or was removed permanently, plus the number of charts and dashboards built on the dataset, counted the same way as `GET /api/v1/dataset/<pk>/related_objects` so inaccessible objects are not disclosed. There is deliberately no purge tool; permanently emptying the trash stays a UI action.
**`restore_dataset`** — restore a trashed dataset through `RestoreDatasetCommand`, with distinct `NotFound`, `NotDeleted` and `LogicalDuplicate` errors.
**`list_datasets`** — a `deleted_state` parameter (`include` / `only`) reusing `DatasetDeletedStateFilter`, so trashed datasets can be found and restored. `DatasetInfo` gains `deleted_at`.
**`update_dataset`** — partially updates `table_name`, `sql` (virtual datasets only), `description`, `main_dttm_col` and `cache_timeout`. It runs the same pair of commands as `PUT /api/v1/dataset/<pk>?override_columns=true`: `UpdateDatasetCommand`, then `RefreshDatasetCommand` when columns need re-syncing. Columns re-sync by default when the SQL changes, as "Sync columns from source" does in the dataset editor, and the response lists added and removed columns so the caller can warn about charts using removed ones. The update commits before the refresh, so a failed refresh is reported as a warning next to the saved change rather than as a failure. Clearing `sql` is rejected, since that would silently turn a virtual dataset into a physical one; columns, metrics, editors and the source database are out of scope.
All three mutating tools are added to `MCP_CACHE_CONFIG["excluded_tools"]`.
### TESTING INSTRUCTIONS
`pytest tests/unit_tests/mcp_service/dataset`
Manually, through an MCP client:
- `create_virtual_dataset`, then `update_dataset` with new `sql` — columns re-sync and `added_columns`/`removed_columns` match the change.
- Set `main_dttm_col` to a column that does not exist — the call is rejected and names the problem.
- `delete_dataset` the same dataset. With `SOFT_DELETE` on, `list_datasets` with `deleted_state="only"` shows it and `restore_dataset` brings it back; with the flag off the delete is permanent and `restore_dataset` returns `NotFound`.
- As a user who is neither owner nor Admin, all three write tools must be refused.
### ADDITIONAL INFORMATION
- [x] Introduces new feature or API
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on code in PR #44337:
URL: https://github.com/apache/superset/pull/44337#discussion_r4101781308
##########
tests/unit_tests/mcp_service/dataset/tool/test_restore_dataset.py:
##########
@@ -0,0 +1,377 @@
+# 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 the restore_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from datetime import datetime
+from typing import Any
+from unittest.mock import Mock, patch
+from uuid import UUID
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_FIND = "superset.daos.dataset.DatasetDAO.find_by_id_or_uuid"
+_COMMAND = "superset.commands.dataset.restore.RestoreDatasetCommand"
+
+_UUID = UUID("11111111-2222-3333-4444-555555555555")
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool's editorship gate calls the real security manager; default
+ # it to a no-op (caller is an editor) so unrelated tests keep passing.
+ # The disclosure tests below re-patch it to raise.
+ with patch("superset.security_manager.raise_for_editorship"):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(
+ dataset_id: int = 10,
+ table_name: str = "orders",
+ deleted: bool = True,
+) -> Mock:
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ dataset.uuid = _UUID
+ dataset.deleted_at = datetime(2026, 7, 1) if deleted else None
+ return dataset
+
+
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_not_found(mock_find: Mock, mcp_server: object) -> None:
+ mock_find.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_not_in_trash(
Review Comment:
<!-- Bito Reply -->
The reviewer's suggestion to add a docstring to the test function is appropriate. Adding a brief docstring that describes the test scenario improves code clarity and maintains consistency with existing tests in the file, as requested by the project's documentation standards.
##########
tests/unit_tests/mcp_service/dataset/tool/test_restore_dataset.py:
##########
@@ -0,0 +1,377 @@
+# 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 the restore_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from datetime import datetime
+from typing import Any
+from unittest.mock import Mock, patch
+from uuid import UUID
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_FIND = "superset.daos.dataset.DatasetDAO.find_by_id_or_uuid"
+_COMMAND = "superset.commands.dataset.restore.RestoreDatasetCommand"
+
+_UUID = UUID("11111111-2222-3333-4444-555555555555")
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool's editorship gate calls the real security manager; default
+ # it to a no-op (caller is an editor) so unrelated tests keep passing.
+ # The disclosure tests below re-patch it to raise.
+ with patch("superset.security_manager.raise_for_editorship"):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(
+ dataset_id: int = 10,
+ table_name: str = "orders",
+ deleted: bool = True,
+) -> Mock:
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ dataset.uuid = _UUID
+ dataset.deleted_at = datetime(2026, 7, 1) if deleted else None
+ return dataset
+
+
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_not_found(mock_find: Mock, mcp_server: object) -> None:
+ mock_find.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_not_in_trash(
+ mock_find: Mock, mcp_server: object
+) -> None:
+ mock_find.return_value = _mock_dataset(deleted=False)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotDeleted"
+ assert "not in trash" in (content["error"] or "").lower()
+
+
+@patch(_COMMAND)
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_success_by_numeric_id(
Review Comment:
<!-- Bito Reply -->
The suggestion to add a docstring to the test function is appropriate, as it aligns with the project's documentation standards and provides clarity on the test scenario. You should apply this change to ensure consistency with the existing test suite.
**tests/unit_tests/mcp_service/dataset/tool/test_restore_dataset.py**
```
@patch(_COMMAND)
@patch(_FIND)
@pytest.mark.asyncio
async def test_restore_dataset_success_by_numeric_id(self) -> None:
"""Test that lookup by numeric ID restores via RestoreDatasetCommand and returns success."""
...
```
##########
tests/unit_tests/mcp_service/dataset/tool/test_restore_dataset.py:
##########
@@ -0,0 +1,377 @@
+# 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 the restore_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from datetime import datetime
+from typing import Any
+from unittest.mock import Mock, patch
+from uuid import UUID
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_FIND = "superset.daos.dataset.DatasetDAO.find_by_id_or_uuid"
+_COMMAND = "superset.commands.dataset.restore.RestoreDatasetCommand"
+
+_UUID = UUID("11111111-2222-3333-4444-555555555555")
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool's editorship gate calls the real security manager; default
+ # it to a no-op (caller is an editor) so unrelated tests keep passing.
+ # The disclosure tests below re-patch it to raise.
+ with patch("superset.security_manager.raise_for_editorship"):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(
+ dataset_id: int = 10,
+ table_name: str = "orders",
+ deleted: bool = True,
+) -> Mock:
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ dataset.uuid = _UUID
+ dataset.deleted_at = datetime(2026, 7, 1) if deleted else None
+ return dataset
+
+
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_not_found(mock_find: Mock, mcp_server: object) -> None:
+ mock_find.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_not_in_trash(
+ mock_find: Mock, mcp_server: object
+) -> None:
+ mock_find.return_value = _mock_dataset(deleted=False)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotDeleted"
+ assert "not in trash" in (content["error"] or "").lower()
+
+
+@patch(_COMMAND)
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_success_by_numeric_id(
+ mock_find: Mock, mock_command: Mock, mcp_server: object
+) -> None:
+ mock_find.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["restored_id"] == 10
+ assert "orders" in content["restored_name"]
+ assert content["permission_denied"] is False
+ mock_command.assert_called_once_with(str(_UUID))
+ mock_command.return_value.run.assert_called_once()
+
+
+@patch(_COMMAND)
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_success_by_uuid(
Review Comment:
<!-- Bito Reply -->
The suggestion to add a docstring to the test function is appropriate, as it aligns with the project's requirement for documenting new test scenarios. Since you have already addressed this by adding the docstrings, no further action is required for this specific comment.
##########
tests/unit_tests/mcp_service/dataset/tool/test_restore_dataset.py:
##########
@@ -0,0 +1,377 @@
+# 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 the restore_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from datetime import datetime
+from typing import Any
+from unittest.mock import Mock, patch
+from uuid import UUID
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_FIND = "superset.daos.dataset.DatasetDAO.find_by_id_or_uuid"
+_COMMAND = "superset.commands.dataset.restore.RestoreDatasetCommand"
+
+_UUID = UUID("11111111-2222-3333-4444-555555555555")
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool's editorship gate calls the real security manager; default
+ # it to a no-op (caller is an editor) so unrelated tests keep passing.
+ # The disclosure tests below re-patch it to raise.
+ with patch("superset.security_manager.raise_for_editorship"):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(
+ dataset_id: int = 10,
+ table_name: str = "orders",
+ deleted: bool = True,
+) -> Mock:
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ dataset.uuid = _UUID
+ dataset.deleted_at = datetime(2026, 7, 1) if deleted else None
+ return dataset
+
+
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_not_found(mock_find: Mock, mcp_server: object) -> None:
+ mock_find.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_not_in_trash(
+ mock_find: Mock, mcp_server: object
+) -> None:
+ mock_find.return_value = _mock_dataset(deleted=False)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotDeleted"
+ assert "not in trash" in (content["error"] or "").lower()
+
+
+@patch(_COMMAND)
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_success_by_numeric_id(
+ mock_find: Mock, mock_command: Mock, mcp_server: object
+) -> None:
+ mock_find.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["restored_id"] == 10
+ assert "orders" in content["restored_name"]
+ assert content["permission_denied"] is False
+ mock_command.assert_called_once_with(str(_UUID))
+ mock_command.return_value.run.assert_called_once()
+
+
+@patch(_COMMAND)
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_success_by_uuid(
+ mock_find: Mock, mock_command: Mock, mcp_server: object
+) -> None:
+ mock_find.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": str(_UUID)}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["restored_id"] == 10
+ mock_find.assert_called_once_with(
+ str(_UUID), skip_base_filter=True, skip_visibility_filter=True
+ )
+ mock_command.assert_called_once_with(str(_UUID))
+
+
+@patch(_COMMAND)
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_permission_denied(
Review Comment:
<!-- Bito Reply -->
The reviewer's suggestion to add a docstring to the test function is appropriate, as it ensures consistency with the existing test suite and provides clarity on the scenario being tested. Since you have confirmed that the docstrings are already in place and the tests are documented, no further action is required for this specific thread.
##########
tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py:
##########
@@ -0,0 +1,336 @@
+# 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 the delete_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_RESOLVE = "superset.mcp_service.dataset.tool.delete_dataset.resolve_dataset"
+_COUNT = "superset.mcp_service.dataset.tool.delete_dataset._count_affected_objects"
+_RUN = "superset.commands.dataset.delete.DeleteDatasetCommand.run"
+_VALIDATE = "superset.commands.dataset.delete.DeleteDatasetCommand.validate"
+_FLAG = "superset.mcp_service.dataset.tool.delete_dataset.is_feature_enabled"
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ """Provide the FastMCP app instance under test."""
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ """Authenticate every tool call as a mock admin user."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool validates the command (lookup + editorship) before counting
+ # dependents; default it to a pass so tests that patch run() alone keep
+ # working. The permission test re-patches it to raise.
+ with patch(_VALIDATE):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(dataset_id: int = 10, table_name: str = "orders") -> Mock:
+ """Build a minimal dataset stand-in with the attributes the tool reads."""
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ return dataset
+
+
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_not_found(mock_resolve: Mock, mcp_server: object) -> None:
+ mock_resolve.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_success(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["deleted_id"] == 10
+ assert "orders" in content["deleted_name"]
+ assert content["permission_denied"] is False
+ assert content["affected_chart_count"] == 0
+ mock_run.assert_called_once()
+ mock_count.assert_called_once_with(10)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_by_uuid(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ uuid = "11111111-2222-3333-4444-555555555555"
+ mock_resolve.return_value = _mock_dataset(dataset_id=10)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": uuid}}
+ )
+
+ assert result.structured_content["success"] is True
+ mock_resolve.assert_called_once_with(uuid)
Review Comment:
<!-- Bito Reply -->
The suggestion to add a docstring to `test_delete_dataset_by_uuid` and type-annotate the `uuid` variable is appropriate. These changes align the new test with the existing codebase's documentation and strict typing standards, improving consistency and maintainability.
**tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py**
```
@patch(_RESOLVE)
@pytest.mark.asyncio
async def test_delete_dataset_by_uuid(
mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
) -> None:
"""Test that the tool correctly resolves and deletes a dataset by UUID."""
uuid: str = "11111111-2222-3333-4444-555555555555"
mock_resolve.return_value = _mock_dataset(dataset_id=10)
```
##########
tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py:
##########
@@ -0,0 +1,336 @@
+# 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 the delete_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_RESOLVE = "superset.mcp_service.dataset.tool.delete_dataset.resolve_dataset"
+_COUNT = "superset.mcp_service.dataset.tool.delete_dataset._count_affected_objects"
+_RUN = "superset.commands.dataset.delete.DeleteDatasetCommand.run"
+_VALIDATE = "superset.commands.dataset.delete.DeleteDatasetCommand.validate"
+_FLAG = "superset.mcp_service.dataset.tool.delete_dataset.is_feature_enabled"
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ """Provide the FastMCP app instance under test."""
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ """Authenticate every tool call as a mock admin user."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool validates the command (lookup + editorship) before counting
+ # dependents; default it to a pass so tests that patch run() alone keep
+ # working. The permission test re-patches it to raise.
+ with patch(_VALIDATE):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(dataset_id: int = 10, table_name: str = "orders") -> Mock:
+ """Build a minimal dataset stand-in with the attributes the tool reads."""
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ return dataset
+
+
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_not_found(mock_resolve: Mock, mcp_server: object) -> None:
Review Comment:
<!-- Bito Reply -->
The suggestion to add a docstring to the `test_delete_dataset_not_found` function is appropriate, as it ensures consistency with the other test functions in the file and adheres to the repository's documentation standards. You can apply this change by adding a one-line docstring describing the not-found path directly above the function definition.
**tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py**
```
@patch(_RESOLVE)
@pytest.mark.asyncio
async def test_delete_dataset_not_found(mock_resolve: Mock, mcp_server: object) -> None:
"""Test that deleting a non-existent dataset raises an error."""
```
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "rusackas (via GitHub)" <gi...@apache.org>.
rusackas merged PR #44337:
URL: https://github.com/apache/superset/pull/44337
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "tien238lnd (via GitHub)" <gi...@apache.org>.
tien238lnd commented on code in PR #44337:
URL: https://github.com/apache/superset/pull/44337#discussion_r4101774765
##########
tests/unit_tests/mcp_service/dataset/tool/test_restore_dataset.py:
##########
@@ -0,0 +1,377 @@
+# 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 the restore_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from datetime import datetime
+from typing import Any
+from unittest.mock import Mock, patch
+from uuid import UUID
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_FIND = "superset.daos.dataset.DatasetDAO.find_by_id_or_uuid"
+_COMMAND = "superset.commands.dataset.restore.RestoreDatasetCommand"
+
+_UUID = UUID("11111111-2222-3333-4444-555555555555")
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool's editorship gate calls the real security manager; default
+ # it to a no-op (caller is an editor) so unrelated tests keep passing.
+ # The disclosure tests below re-patch it to raise.
+ with patch("superset.security_manager.raise_for_editorship"):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(
+ dataset_id: int = 10,
+ table_name: str = "orders",
+ deleted: bool = True,
+) -> Mock:
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ dataset.uuid = _UUID
+ dataset.deleted_at = datetime(2026, 7, 1) if deleted else None
+ return dataset
+
+
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_not_found(mock_find: Mock, mcp_server: object) -> None:
+ mock_find.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_not_in_trash(
+ mock_find: Mock, mcp_server: object
+) -> None:
+ mock_find.return_value = _mock_dataset(deleted=False)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotDeleted"
+ assert "not in trash" in (content["error"] or "").lower()
+
+
+@patch(_COMMAND)
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_success_by_numeric_id(
+ mock_find: Mock, mock_command: Mock, mcp_server: object
+) -> None:
+ mock_find.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["restored_id"] == 10
+ assert "orders" in content["restored_name"]
+ assert content["permission_denied"] is False
+ mock_command.assert_called_once_with(str(_UUID))
+ mock_command.return_value.run.assert_called_once()
+
+
+@patch(_COMMAND)
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_success_by_uuid(
+ mock_find: Mock, mock_command: Mock, mcp_server: object
+) -> None:
+ mock_find.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": str(_UUID)}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["restored_id"] == 10
+ mock_find.assert_called_once_with(
+ str(_UUID), skip_base_filter=True, skip_visibility_filter=True
+ )
+ mock_command.assert_called_once_with(str(_UUID))
+
+
+@patch(_COMMAND)
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_permission_denied(
+ mock_find: Mock, mock_command: Mock, mcp_server: object
+) -> None:
+ from superset.commands.dataset.exceptions import DatasetForbiddenError
Review Comment:
Moved to module level in 1e4ef6938, next to the other `superset` imports.
_🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
##########
superset/mcp_service/dataset/schemas.py:
##########
@@ -718,6 +739,246 @@ class UpdateDatasetMetricResponse(BaseModel):
)
+class DeleteDatasetRequest(BaseModel):
+ """Request schema for delete_dataset."""
+
+ identifier: int | str = Field(
+ ...,
+ description=(
+ "Dataset identifier - numeric ID or UUID string (NOT the table name)."
+ ),
+ )
+
+ @field_validator("identifier", mode="before")
+ @classmethod
+ def reject_bool_identifier(cls, value: object) -> object:
+ """bool is a subclass of int, so identifier=true would coerce to
+ dataset ID 1 and delete the wrong object; reject it outright."""
+ if isinstance(value, bool):
+ raise ValueError("identifier must be an integer ID or UUID string")
+ return value
+
+
+class DeleteDatasetResponse(BaseModel):
+ """Result of a delete_dataset operation."""
+
+ success: bool = Field(description="Whether the dataset was deleted")
+ deleted_id: int | None = Field(None, description="ID of the deleted dataset")
+ deleted_name: str | None = Field(
+ None, description="Table name of the deleted dataset"
+ )
+ soft_deleted: bool = Field(
+ False,
+ description=(
+ "True when the dataset was soft-deleted (moved to trash, because the "
+ "SOFT_DELETE feature flag is enabled) and can be restored by an "
+ "owner or Admin. False means the delete was permanent."
+ ),
+ )
+ affected_chart_count: int = Field(
+ 0,
+ description=(
+ "Number of charts (visible to the caller) built on this dataset. "
+ "They stop working while the dataset is deleted."
+ ),
+ )
+ affected_dashboard_count: int = Field(
+ 0,
+ description=(
+ "Number of dashboards (visible to the caller) containing those charts."
+ ),
+ )
+ message: str | None = Field(None, description="Human-readable outcome message")
+ error: str | None = Field(None, description="Error message if the delete failed")
+ error_type: str | None = Field(None, description="Type of error if failed")
+ permission_denied: bool = Field(
+ False,
+ description=(
+ "True when the caller lacks permission to delete the dataset (do not "
+ "retry; ask the user)."
+ ),
+ )
+
+
+class RestoreDatasetRequest(BaseModel):
+ """Request schema for restore_dataset."""
+
+ identifier: int | str = Field(
+ ...,
+ description=(
+ "Dataset identifier - numeric ID or UUID string (NOT the table name)."
+ ),
+ )
+
+ @field_validator("identifier", mode="before")
+ @classmethod
+ def reject_bool_identifier(cls, value: object) -> object:
+ """bool is a subclass of int, so identifier=true would coerce to
+ dataset ID 1 and target the wrong object; reject it outright."""
+ if isinstance(value, bool):
+ raise ValueError("identifier must be an integer ID or UUID string")
+ return value
+
+
+class RestoreDatasetResponse(BaseModel):
+ """Result of a restore_dataset operation."""
+
+ success: bool = Field(description="Whether the dataset was restored from trash")
+ restored_id: int | None = Field(None, description="ID of the restored dataset")
+ restored_name: str | None = Field(
+ None, description="Table name of the restored dataset"
+ )
+ message: str | None = Field(None, description="Human-readable outcome message")
+ error: str | None = Field(None, description="Error message if the restore failed")
+ error_type: str | None = Field(None, description="Type of error if failed")
+ permission_denied: bool = Field(
+ False,
+ description=(
+ "True when the caller lacks permission to restore the dataset (do not "
+ "retry; ask the user)."
+ ),
+ )
+
+
+UPDATABLE_DATASET_FIELDS: frozenset[str] = frozenset(
+ {
+ "table_name",
+ "sql",
+ "description",
+ "main_dttm_col",
+ "cache_timeout",
+ }
+)
+
+
+class UpdateDatasetRequest(BaseModel):
+ """Request schema for update_dataset."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ dataset_id: int | str = Field(
Review Comment:
Addressed in 1e4ef6938 without a rename: `dataset_id` now also accepts `identifier`, the way `GetDatasetInfoRequest` already accepts `identifier`, `id` and `dataset_id`. Renaming the field outright would break callers, and `dataset_id` matches `update_dataset_metric` on master.
_🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
##########
tests/unit_tests/mcp_service/test_rbac_tool_enforcement.py:
##########
@@ -110,6 +110,24 @@
"write",
"Dataset",
),
+ (
+ "update_dataset",
+ {"dataset_id": 1, "description": "denied"},
+ "write",
+ "Dataset",
+ ),
+ (
+ "delete_dataset",
+ {"identifier": 1},
+ "write",
+ "Dataset",
+ ),
+ (
+ "restore_dataset",
+ {"identifier": 1},
+ "write",
+ "Dataset",
+ ),
Review Comment:
This one does not hold. The permission is derived from the tool's tags: `core_mcp_injection.py` sets it to `"write" if "mutate" in tool_tags else "read"` before the auth hook wraps the function, and both tools declare `tags=["mutate"]`. The `can_read` default at `auth.py:397` only applies when no tag and no explicit name give one. The two rbac cases pass at this head, and flipping the expectation to `can_read` would assert a hole rather than the behaviour.
##########
superset/mcp_service/dataset/tool/update_dataset.py:
##########
@@ -0,0 +1,320 @@
+# 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.
+
+"""
+MCP tool: update_dataset
+"""
+
+import logging
+from typing import Any
+
+from fastmcp import Context
+from sqlalchemy.exc import SQLAlchemyError
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset import security_manager
+from superset.exceptions import SupersetException
+from superset.extensions import event_logger
+from superset.mcp_service.dataset.schemas import (
+ UpdateDatasetRequest,
+ UpdateDatasetResponse,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _column_names(dataset: Any) -> set[str]:
+ return {column.column_name for column in dataset.columns}
+
+
+def _sync_error_message(ex: Exception) -> str:
+ # Raw SQLAlchemy text can leak SQL or connection details; Superset
+ # exception messages are user-facing by design.
+ if isinstance(ex, SQLAlchemyError):
+ return "a database error occurred"
+ return str(ex)
+
+
+@tool(
+ tags=["mutate"],
+ class_permission_name="Dataset",
+ method_permission_name="write",
+ annotations=ToolAnnotations(
+ title="Update dataset",
+ readOnlyHint=False,
+ # Rewriting a virtual dataset's SQL or re-syncing its columns changes
+ # what every chart built on it queries — non-additive, like
+ # update_chart.
+ destructiveHint=True,
+ idempotentHint=False,
+ openWorldHint=False,
+ ),
+)
+async def update_dataset( # noqa: C901
Review Comment:
Leaving it. The length is the update sequence itself — resolve, gate, validate against the dataset's columns, update, re-sync, apply the deferred datetime column, report — and each step reads the result of the one before it. Splitting it into helpers that pass `dataset`, `updates`, `warnings` and `columns_synced` around would move the sequencing into their call order without making any of it easier to test; the branches are covered directly through the tool today.
##########
tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py:
##########
@@ -0,0 +1,336 @@
+# 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 the delete_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_RESOLVE = "superset.mcp_service.dataset.tool.delete_dataset.resolve_dataset"
+_COUNT = "superset.mcp_service.dataset.tool.delete_dataset._count_affected_objects"
+_RUN = "superset.commands.dataset.delete.DeleteDatasetCommand.run"
+_VALIDATE = "superset.commands.dataset.delete.DeleteDatasetCommand.validate"
+_FLAG = "superset.mcp_service.dataset.tool.delete_dataset.is_feature_enabled"
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ """Provide the FastMCP app instance under test."""
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ """Authenticate every tool call as a mock admin user."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool validates the command (lookup + editorship) before counting
+ # dependents; default it to a pass so tests that patch run() alone keep
+ # working. The permission test re-patches it to raise.
+ with patch(_VALIDATE):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(dataset_id: int = 10, table_name: str = "orders") -> Mock:
+ """Build a minimal dataset stand-in with the attributes the tool reads."""
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ return dataset
+
+
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_not_found(mock_resolve: Mock, mcp_server: object) -> None:
+ mock_resolve.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_success(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["deleted_id"] == 10
+ assert "orders" in content["deleted_name"]
+ assert content["permission_denied"] is False
+ assert content["affected_chart_count"] == 0
+ mock_run.assert_called_once()
+ mock_count.assert_called_once_with(10)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_by_uuid(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ uuid = "11111111-2222-3333-4444-555555555555"
+ mock_resolve.return_value = _mock_dataset(dataset_id=10)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": uuid}}
+ )
+
+ assert result.structured_content["success"] is True
+ mock_resolve.assert_called_once_with(uuid)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_FLAG)
Review Comment:
Keeping the lambda: a one-argument flag check is what `side_effect` is for, and a named helper here would say less than the line it replaces.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on code in PR #44337:
URL: https://github.com/apache/superset/pull/44337#discussion_r4036857680
##########
tests/unit_tests/mcp_service/test_rbac_tool_enforcement.py:
##########
@@ -110,6 +110,24 @@
"write",
"Dataset",
),
+ (
+ "update_dataset",
+ {"dataset_id": 1, "description": "denied"},
+ "write",
+ "Dataset",
+ ),
+ (
+ "delete_dataset",
+ {"identifier": 1},
+ "write",
+ "Dataset",
+ ),
+ (
+ "restore_dataset",
+ {"identifier": 1},
+ "write",
+ "Dataset",
+ ),
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>RBAC permission mismatch</b></div>
<div id="fix">
These tests assert `can_access("can_write", "Dataset")`, but `delete_dataset`/`restore_dataset` declare only `class_permission_name="Dataset"` with no `method_permission_name`, so `mcp_auth_hook` defaults to `can_read` (auth.py:397). The `assert_called_with("can_write", ...)` will fail against the current tools. Add `method_permission_name="write"` to both tool decorators, or align the test expectation.
</div>
</div>
<div><details><summary><b>Review Rule</b></summary><div>Bito created the rule <strong><code>Mutating operations require write permission</code></strong> for <strong>repo:</strong> <code>apache/superset</code>, <strong>language:</strong> <code>Python</code>. Bito will avoid suggestions that match this rule. You can manage review rules <a href=https://alpha.bito.ai/home/ai-agents/review-rules>here</a>.</div></details></div>
<small><i>Code Review Run #81edb1</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on code in PR #44337:
URL: https://github.com/apache/superset/pull/44337#discussion_r4037407300
##########
tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py:
##########
@@ -0,0 +1,336 @@
+# 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 the delete_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_RESOLVE = "superset.mcp_service.dataset.tool.delete_dataset.resolve_dataset"
+_COUNT = "superset.mcp_service.dataset.tool.delete_dataset._count_affected_objects"
+_RUN = "superset.commands.dataset.delete.DeleteDatasetCommand.run"
+_VALIDATE = "superset.commands.dataset.delete.DeleteDatasetCommand.validate"
+_FLAG = "superset.mcp_service.dataset.tool.delete_dataset.is_feature_enabled"
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ """Provide the FastMCP app instance under test."""
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ """Authenticate every tool call as a mock admin user."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool validates the command (lookup + editorship) before counting
+ # dependents; default it to a pass so tests that patch run() alone keep
+ # working. The permission test re-patches it to raise.
+ with patch(_VALIDATE):
+ mock_user = Mock()
Review Comment:
<!-- Bito Reply -->
The suggestion to add `: Mock` annotations to the `mock_auth` and `_mock_dataset` fixtures is valid and improves code clarity by explicitly defining the types of the mock objects, which aligns with the repository's coding standards for local variables.
**tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py**
```
with patch(_VALIDATE):
mock_user: Mock = Mock()
```
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "codecov[bot] (via GitHub)" <gi...@apache.org>.
codecov[bot] commented on PR #44337:
URL: https://github.com/apache/superset/pull/44337#issuecomment-5694028967
## [Codecov](https://app.codecov.io/gh/apache/superset/pull/44337?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) Report
:x: Patch coverage is `34.76821%` with `197 lines` in your changes missing coverage. Please review.
:white_check_mark: Project coverage is 57.46%. Comparing base ([`9976e08`](https://app.codecov.io/gh/apache/superset/commit/9976e08333c086861207afb01f8f2ea62c079931?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache)) to head ([`1a88e32`](https://app.codecov.io/gh/apache/superset/commit/1a88e32da2471e4650839cc3d945822484bea758?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache)).
:warning: Report is 1 commits behind head on master.
| [Files with missing lines](https://app.codecov.io/gh/apache/superset/pull/44337?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | Patch % | Lines |
|---|---|---|
| [...uperset/mcp\_service/dataset/tool/update\_dataset.py](https://app.codecov.io/gh/apache/superset/pull/44337?src=pr&el=tree&filepath=superset%2Fmcp_service%2Fdataset%2Ftool%2Fupdate_dataset.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-c3VwZXJzZXQvbWNwX3NlcnZpY2UvZGF0YXNldC90b29sL3VwZGF0ZV9kYXRhc2V0LnB5) | 14.00% | [86 Missing :warning: ](https://app.codecov.io/gh/apache/superset/pull/44337?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
| [...uperset/mcp\_service/dataset/tool/delete\_dataset.py](https://app.codecov.io/gh/apache/superset/pull/44337?src=pr&el=tree&filepath=superset%2Fmcp_service%2Fdataset%2Ftool%2Fdelete_dataset.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-c3VwZXJzZXQvbWNwX3NlcnZpY2UvZGF0YXNldC90b29sL2RlbGV0ZV9kYXRhc2V0LnB5) | 23.52% | [52 Missing :warning: ](https://app.codecov.io/gh/apache/superset/pull/44337?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
| [...perset/mcp\_service/dataset/tool/restore\_dataset.py](https://app.codecov.io/gh/apache/superset/pull/44337?src=pr&el=tree&filepath=superset%2Fmcp_service%2Fdataset%2Ftool%2Frestore_dataset.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-c3VwZXJzZXQvbWNwX3NlcnZpY2UvZGF0YXNldC90b29sL3Jlc3RvcmVfZGF0YXNldC5weQ==) | 25.92% | [40 Missing :warning: ](https://app.codecov.io/gh/apache/superset/pull/44337?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
| [superset/mcp\_service/dataset/schemas.py](https://app.codecov.io/gh/apache/superset/pull/44337?src=pr&el=tree&filepath=superset%2Fmcp_service%2Fdataset%2Fschemas.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-c3VwZXJzZXQvbWNwX3NlcnZpY2UvZGF0YXNldC9zY2hlbWFzLnB5) | 76.31% | [18 Missing :warning: ](https://app.codecov.io/gh/apache/superset/pull/44337?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
| [superset/mcp\_service/dataset/tool/list\_datasets.py](https://app.codecov.io/gh/apache/superset/pull/44337?src=pr&el=tree&filepath=superset%2Fmcp_service%2Fdataset%2Ftool%2Flist_datasets.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-c3VwZXJzZXQvbWNwX3NlcnZpY2UvZGF0YXNldC90b29sL2xpc3RfZGF0YXNldHMucHk=) | 0.00% | [1 Missing :warning: ](https://app.codecov.io/gh/apache/superset/pull/44337?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
> :exclamation: There is a different number of reports uploaded between BASE (9976e08) and HEAD (1a88e32). Click for more details.
>
> <details><summary>HEAD has 48 uploads less than BASE</summary>
>
>| Flag | BASE (9976e08) | HEAD (1a88e32) |
>|------|------|------|
>|python|38|2|
>|presto|7|1|
>|hive|7|1|
></details>
<details><summary>Additional details and impacted files</summary>
```diff
@@ Coverage Diff @@
## master #44337 +/- ##
===========================================
- Coverage 80.25% 57.46% -22.79%
===========================================
Files 2927 2930 +3
Lines 173273 173574 +301
Branches 40173 40197 +24
===========================================
- Hits 139058 99747 -39311
- Misses 31620 72880 +41260
+ Partials 2595 947 -1648
```
| [Flag](https://app.codecov.io/gh/apache/superset/pull/44337/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | Coverage Δ | |
|---|---|---|
| [hive](https://app.codecov.io/gh/apache/superset/pull/44337/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `37.33% <34.76%> (-0.01%)` | :arrow_down: |
| [mysql](https://app.codecov.io/gh/apache/superset/pull/44337/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `?` | |
| [postgres](https://app.codecov.io/gh/apache/superset/pull/44337/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `?` | |
| [presto](https://app.codecov.io/gh/apache/superset/pull/44337/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `39.42% <34.76%> (-0.02%)` | :arrow_down: |
| [python](https://app.codecov.io/gh/apache/superset/pull/44337/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `39.47% <34.76%> (-45.21%)` | :arrow_down: |
| [sqlite](https://app.codecov.io/gh/apache/superset/pull/44337/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `?` | |
| [unit](https://app.codecov.io/gh/apache/superset/pull/44337/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `?` | |
Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#carryforward-flags-in-the-pull-request-comment) to find out more.
</details>
[:umbrella: View full report in Codecov by Harness](https://app.codecov.io/gh/apache/superset/pull/44337?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache).
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache).
<details><summary> :rocket: New features to boost your workflow: </summary>
- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "tien238lnd (via GitHub)" <gi...@apache.org>.
tien238lnd commented on code in PR #44337:
URL: https://github.com/apache/superset/pull/44337#discussion_r4089247291
##########
tests/unit_tests/mcp_service/dataset/tool/test_list_datasets_deleted_state.py:
##########
@@ -0,0 +1,205 @@
+# 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_datasets ``deleted_state`` trash listing.
+
+The deleted_state param opts the list query into surfacing soft-deleted
+datasets: the session-scoped visibility bypass wraps the DAO call, the REST
+``DatasetDeletedStateFilter`` (which owns the restore-audience scoping) is
+passed through as a DAO custom filter, and ``deleted_at`` is forced into the
+loaded columns so trashed rows are distinguishable in the response.
+"""
+
+import importlib
+from collections.abc import Iterator
+from datetime import datetime
+from unittest.mock import MagicMock, Mock, patch
+from uuid import UUID
+
+import pytest
+from fastmcp import Client
+from fastmcp.exceptions import ToolError
+
+from superset.mcp_service.app import mcp
+from superset.utils import json
+
+_DAO_LIST = "superset.daos.dataset.DatasetDAO.list"
+_BYPASS = "superset.mcp_service.mcp_core.skip_visibility_filter"
+
+list_datasets_module = importlib.import_module(
+ "superset.mcp_service.dataset.tool.list_datasets"
+)
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ 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() -> Iterator[None]:
+ """Keep the tests on the metadata-allowed path; privacy gating is covered
+ in test_dataset_tools."""
+ with patch.object(
+ list_datasets_module, "user_can_view_data_model_metadata", return_value=True
+ ):
+ yield
+
+
+def _trashed_dataset_row() -> Mock:
+ row = Mock(
+ spec=[
+ "id",
+ "table_name",
+ "schema",
+ "changed_on",
+ "created_on",
+ "uuid",
+ "deleted_at",
+ ]
+ )
+ row.id = 10
+ row.table_name = "trashed_orders"
+ row.schema = "public"
+ row.changed_on = datetime(2026, 6, 1)
+ row.created_on = datetime(2026, 5, 1)
+ row.uuid = UUID("11111111-2222-3333-4444-555555555555")
+ row.deleted_at = datetime(2026, 7, 1)
+ return row
+
+
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_datasets_deleted_state_only_passes_custom_filter(
+ mock_list: Mock, mcp_server: object
+) -> None:
+ mock_list.return_value = ([], 0)
+
+ async with Client(mcp_server) as client:
+ await client.call_tool("list_datasets", {"request": {"deleted_state": "only"}})
+
+ kwargs = mock_list.call_args.kwargs
+ assert "deleted_state" in (kwargs.get("custom_filters") or {})
+ assert "deleted_at" in kwargs["columns"]
+
+
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_datasets_deleted_state_combines_with_certified(
+ mock_list: Mock, mcp_server: object
+) -> None:
+ mock_list.return_value = ([], 0)
+
+ async with Client(mcp_server) as client:
+ await client.call_tool(
+ "list_datasets",
+ {"request": {"deleted_state": "include", "certified": True}},
+ )
+
+ custom_filters = mock_list.call_args.kwargs.get("custom_filters") or {}
+ assert {"deleted_state", "certified"} <= set(custom_filters)
+
+
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_datasets_default_has_no_deleted_state_filter(
+ mock_list: Mock, mcp_server: object
+) -> None:
+ mock_list.return_value = ([], 0)
+
+ async with Client(mcp_server) as client:
+ await client.call_tool("list_datasets", {})
+
+ kwargs = mock_list.call_args.kwargs
+ assert "deleted_state" not in (kwargs.get("custom_filters") or {})
+
+
+@patch(_BYPASS)
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_datasets_deleted_state_wraps_visibility_bypass(
+ mock_list: Mock, mock_bypass: MagicMock, mcp_server: object
+) -> None:
+ from superset.connectors.sqla.models import SqlaTable
+
+ mock_list.return_value = ([], 0)
+ mock_bypass.return_value.__enter__ = Mock(return_value=None)
+ mock_bypass.return_value.__exit__ = Mock(return_value=False)
+
+ async with Client(mcp_server) as client:
+ await client.call_tool(
+ "list_datasets", {"request": {"deleted_state": "include"}}
+ )
+
+ mock_bypass.assert_called_once()
+ assert SqlaTable in mock_bypass.call_args.args
+
+
+@patch(_BYPASS)
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_datasets_no_deleted_state_no_visibility_bypass(
+ mock_list: Mock, mock_bypass: MagicMock, mcp_server: object
+) -> None:
+ mock_list.return_value = ([], 0)
+
+ async with Client(mcp_server) as client:
+ await client.call_tool("list_datasets", {})
+
+ mock_bypass.assert_not_called()
+
+
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_datasets_deleted_state_serializes_deleted_at(
+ mock_list: Mock, mcp_server: object
+) -> None:
+ mock_list.return_value = ([_trashed_dataset_row()], 1)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "list_datasets", {"request": {"deleted_state": "only"}}
+ )
+
+ data = json.loads(result.content[0].text)
+ assert data["datasets"][0]["id"] == 10
+ assert data["datasets"][0]["deleted_at"] is not None
+ assert "deleted_at" in data["columns_loaded"]
+
+
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_datasets_deleted_state_invalid_value_rejected(
+ mock_list: Mock, mcp_server: object
+) -> None:
Review Comment:
Added: the test now says that a deleted_state outside the three accepted values is refused.
##########
tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py:
##########
@@ -0,0 +1,344 @@
+# 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 the delete_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_RESOLVE = "superset.mcp_service.dataset.tool.delete_dataset.resolve_dataset"
+_COUNT = "superset.mcp_service.dataset.tool.delete_dataset._count_affected_objects"
+_RUN = "superset.commands.dataset.delete.DeleteDatasetCommand.run"
+_VALIDATE = "superset.commands.dataset.delete.DeleteDatasetCommand.validate"
+_FLAG = "superset.mcp_service.dataset.tool.delete_dataset.is_feature_enabled"
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ """Provide the FastMCP app instance under test."""
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ """Authenticate every tool call as a mock admin user."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool validates the command (lookup + editorship) before counting
+ # dependents; default it to a pass so tests that patch run() alone keep
+ # working. The permission test re-patches it to raise.
+ with patch(_VALIDATE):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(dataset_id: int = 10, table_name: str = "orders") -> Mock:
+ """Build a minimal dataset stand-in with the attributes the tool reads."""
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ return dataset
+
+
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_not_found(mock_resolve: Mock, mcp_server: object) -> None:
+ """An identifier that resolves to no dataset returns NotFound naming it."""
+ mock_resolve.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_success(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ """The happy path: the dataset is resolved, deleted and its dependents counted."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["deleted_id"] == 10
+ assert "orders" in content["deleted_name"]
+ assert content["permission_denied"] is False
+ assert content["affected_chart_count"] == 0
+ mock_run.assert_called_once()
+ mock_count.assert_called_once_with(10)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_by_uuid(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ """A UUID identifier is passed through to the dataset lookup unchanged."""
+ uuid = "11111111-2222-3333-4444-555555555555"
+ mock_resolve.return_value = _mock_dataset(dataset_id=10)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": uuid}}
+ )
+
+ assert result.structured_content["success"] is True
+ mock_resolve.assert_called_once_with(uuid)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_FLAG)
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_soft_delete_reports_restorable(
+ mock_resolve: Mock,
+ mock_run: Mock,
+ mock_flag: Mock,
+ mock_count: Mock,
+ mcp_server: object,
+) -> None:
+ """With SOFT_DELETE on, the response says the dataset can be restored."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+ mock_flag.side_effect = lambda flag: flag == "SOFT_DELETE"
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["soft_deleted"] is True
+ assert "restor" in (content["message"] or "").lower()
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_FLAG)
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_hard_delete_reports_permanent(
+ mock_resolve: Mock,
+ mock_run: Mock,
+ mock_flag: Mock,
+ mock_count: Mock,
+ mcp_server: object,
+) -> None:
+ """With SOFT_DELETE off, the response says the delete is permanent."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+ mock_flag.return_value = False
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["soft_deleted"] is False
+ assert "permanent" in (content["message"] or "").lower()
+
+
+@patch(_COUNT, return_value=(3, 2))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_reports_affected_charts(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ """Dependent charts and dashboards are counted in the response."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["affected_chart_count"] == 3
+ assert content["affected_dashboard_count"] == 2
+ assert "3 chart(s)" in (content["message"] or "")
+
+
+def test_count_affected_objects_only_counts_accessible() -> None:
+ """Counts must not disclose charts/dashboards the caller cannot access."""
+ from superset.mcp_service.dataset.tool.delete_dataset import (
+ _count_affected_objects,
+ )
Review Comment:
Moved to module level in 4321f8075 — there is no circular dependency here, `delete_dataset.py` imports it at module level too.
##########
tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py:
##########
@@ -0,0 +1,344 @@
+# 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 the delete_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_RESOLVE = "superset.mcp_service.dataset.tool.delete_dataset.resolve_dataset"
+_COUNT = "superset.mcp_service.dataset.tool.delete_dataset._count_affected_objects"
+_RUN = "superset.commands.dataset.delete.DeleteDatasetCommand.run"
+_VALIDATE = "superset.commands.dataset.delete.DeleteDatasetCommand.validate"
+_FLAG = "superset.mcp_service.dataset.tool.delete_dataset.is_feature_enabled"
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ """Provide the FastMCP app instance under test."""
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ """Authenticate every tool call as a mock admin user."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool validates the command (lookup + editorship) before counting
+ # dependents; default it to a pass so tests that patch run() alone keep
+ # working. The permission test re-patches it to raise.
+ with patch(_VALIDATE):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(dataset_id: int = 10, table_name: str = "orders") -> Mock:
+ """Build a minimal dataset stand-in with the attributes the tool reads."""
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ return dataset
+
+
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_not_found(mock_resolve: Mock, mcp_server: object) -> None:
+ """An identifier that resolves to no dataset returns NotFound naming it."""
+ mock_resolve.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_success(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ """The happy path: the dataset is resolved, deleted and its dependents counted."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["deleted_id"] == 10
+ assert "orders" in content["deleted_name"]
+ assert content["permission_denied"] is False
+ assert content["affected_chart_count"] == 0
+ mock_run.assert_called_once()
+ mock_count.assert_called_once_with(10)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_by_uuid(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ """A UUID identifier is passed through to the dataset lookup unchanged."""
+ uuid = "11111111-2222-3333-4444-555555555555"
+ mock_resolve.return_value = _mock_dataset(dataset_id=10)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": uuid}}
+ )
+
+ assert result.structured_content["success"] is True
+ mock_resolve.assert_called_once_with(uuid)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_FLAG)
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_soft_delete_reports_restorable(
+ mock_resolve: Mock,
+ mock_run: Mock,
+ mock_flag: Mock,
+ mock_count: Mock,
+ mcp_server: object,
+) -> None:
+ """With SOFT_DELETE on, the response says the dataset can be restored."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+ mock_flag.side_effect = lambda flag: flag == "SOFT_DELETE"
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["soft_deleted"] is True
+ assert "restor" in (content["message"] or "").lower()
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_FLAG)
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_hard_delete_reports_permanent(
+ mock_resolve: Mock,
+ mock_run: Mock,
+ mock_flag: Mock,
+ mock_count: Mock,
+ mcp_server: object,
+) -> None:
+ """With SOFT_DELETE off, the response says the delete is permanent."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+ mock_flag.return_value = False
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["soft_deleted"] is False
+ assert "permanent" in (content["message"] or "").lower()
+
+
+@patch(_COUNT, return_value=(3, 2))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_reports_affected_charts(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ """Dependent charts and dashboards are counted in the response."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["affected_chart_count"] == 3
+ assert content["affected_dashboard_count"] == 2
+ assert "3 chart(s)" in (content["message"] or "")
+
+
+def test_count_affected_objects_only_counts_accessible() -> None:
+ """Counts must not disclose charts/dashboards the caller cannot access."""
+ from superset.mcp_service.dataset.tool.delete_dataset import (
+ _count_affected_objects,
+ )
+
+ visible_chart, hidden_chart = Mock(), Mock()
+ visible_dashboard, hidden_dashboard = Mock(), Mock()
+ security_manager = Mock()
+ security_manager.can_access_chart.side_effect = lambda c: c is visible_chart
+ security_manager.can_access_dashboard.side_effect = lambda d: d is visible_dashboard
Review Comment:
Keeping these. A one-argument identity check is what `side_effect` is for, and naming them would add two helpers that say less than the lambdas do. The types are already pinned by the `Mock()` objects the test builds.
##########
tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py:
##########
@@ -0,0 +1,344 @@
+# 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 the delete_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_RESOLVE = "superset.mcp_service.dataset.tool.delete_dataset.resolve_dataset"
+_COUNT = "superset.mcp_service.dataset.tool.delete_dataset._count_affected_objects"
+_RUN = "superset.commands.dataset.delete.DeleteDatasetCommand.run"
+_VALIDATE = "superset.commands.dataset.delete.DeleteDatasetCommand.validate"
+_FLAG = "superset.mcp_service.dataset.tool.delete_dataset.is_feature_enabled"
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ """Provide the FastMCP app instance under test."""
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ """Authenticate every tool call as a mock admin user."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool validates the command (lookup + editorship) before counting
+ # dependents; default it to a pass so tests that patch run() alone keep
+ # working. The permission test re-patches it to raise.
+ with patch(_VALIDATE):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(dataset_id: int = 10, table_name: str = "orders") -> Mock:
+ """Build a minimal dataset stand-in with the attributes the tool reads."""
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ return dataset
+
+
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_not_found(mock_resolve: Mock, mcp_server: object) -> None:
+ """An identifier that resolves to no dataset returns NotFound naming it."""
+ mock_resolve.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_success(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ """The happy path: the dataset is resolved, deleted and its dependents counted."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["deleted_id"] == 10
+ assert "orders" in content["deleted_name"]
+ assert content["permission_denied"] is False
+ assert content["affected_chart_count"] == 0
+ mock_run.assert_called_once()
+ mock_count.assert_called_once_with(10)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_by_uuid(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ """A UUID identifier is passed through to the dataset lookup unchanged."""
+ uuid = "11111111-2222-3333-4444-555555555555"
+ mock_resolve.return_value = _mock_dataset(dataset_id=10)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": uuid}}
+ )
+
+ assert result.structured_content["success"] is True
+ mock_resolve.assert_called_once_with(uuid)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_FLAG)
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_soft_delete_reports_restorable(
+ mock_resolve: Mock,
+ mock_run: Mock,
+ mock_flag: Mock,
+ mock_count: Mock,
+ mcp_server: object,
+) -> None:
+ """With SOFT_DELETE on, the response says the dataset can be restored."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+ mock_flag.side_effect = lambda flag: flag == "SOFT_DELETE"
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["soft_deleted"] is True
+ assert "restor" in (content["message"] or "").lower()
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_FLAG)
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_hard_delete_reports_permanent(
+ mock_resolve: Mock,
+ mock_run: Mock,
+ mock_flag: Mock,
+ mock_count: Mock,
+ mcp_server: object,
+) -> None:
+ """With SOFT_DELETE off, the response says the delete is permanent."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+ mock_flag.return_value = False
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["soft_deleted"] is False
+ assert "permanent" in (content["message"] or "").lower()
+
+
+@patch(_COUNT, return_value=(3, 2))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_reports_affected_charts(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ """Dependent charts and dashboards are counted in the response."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["affected_chart_count"] == 3
+ assert content["affected_dashboard_count"] == 2
+ assert "3 chart(s)" in (content["message"] or "")
+
+
+def test_count_affected_objects_only_counts_accessible() -> None:
+ """Counts must not disclose charts/dashboards the caller cannot access."""
+ from superset.mcp_service.dataset.tool.delete_dataset import (
+ _count_affected_objects,
+ )
+
+ visible_chart, hidden_chart = Mock(), Mock()
+ visible_dashboard, hidden_dashboard = Mock(), Mock()
+ security_manager = Mock()
+ security_manager.can_access_chart.side_effect = lambda c: c is visible_chart
+ security_manager.can_access_dashboard.side_effect = lambda d: d is visible_dashboard
+
+ with (
+ patch(
+ "superset.daos.dataset.DatasetDAO.get_related_objects",
+ return_value={
+ "charts": [visible_chart, hidden_chart],
+ "dashboards": [visible_dashboard, hidden_dashboard],
+ },
+ ) as mock_related,
+ patch("superset.security_manager", security_manager),
+ ):
+ assert _count_affected_objects(10) == (1, 1)
+
+ mock_related.assert_called_once_with(10)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_permission_denied(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ """A caller who may not delete the dataset gets Forbidden before any
+ dependents are counted or anything is deleted."""
+ from superset.commands.dataset.exceptions import DatasetForbiddenError
Review Comment:
Moved to module level in 4321f8075, same as the other one.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #44337:
URL: https://github.com/apache/superset/pull/44337#issuecomment-5758846331
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
<details>
<summary>
Bito Usage Guide
</summary>
**Commands**
Type the following command in the pull request comment and save the comment.
- `/review` - Manually triggers an incremental AI Review.
- `/review full` - Manually triggers a full AI Review.
- `/pause` - Pauses automatic reviews on this pull request.
- `/resume` - Resumes automatic reviews.
- `/resolve` - Marks all Bito-posted review comments as resolved.
- `/abort` - Cancels all in-progress reviews.
Refer to the <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/available-commands" target="_blank" rel="noopener noreferrer">documentation</a> for additional commands.
**Configuration**
This repository uses `Superset` You can customize the agent settings <a href="https://alpha.bito.ai/home/ai-agents/code-review-agent" target="_blank" rel="noopener noreferrer">here</a> or contact your Bito workspace admin at evan@preset.io.
**Documentation & Help**
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/getting-started/install-run-using-bito-cloud/create-or-customize-an-agent-instance" target="_blank" rel="noopener noreferrer">Customize agent settings</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/implementing-custom-code-review-rules" target="_blank" rel="noopener noreferrer">Review rules</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent" target="_blank" rel="noopener noreferrer">General documentation</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/faqs" target="_blank" rel="noopener noreferrer">FAQ</a>
</details>
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on code in PR #44337:
URL: https://github.com/apache/superset/pull/44337#discussion_r4036857062
##########
tests/unit_tests/mcp_service/dataset/tool/test_restore_dataset.py:
##########
@@ -0,0 +1,377 @@
+# 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 the restore_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from datetime import datetime
+from typing import Any
+from unittest.mock import Mock, patch
+from uuid import UUID
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_FIND = "superset.daos.dataset.DatasetDAO.find_by_id_or_uuid"
+_COMMAND = "superset.commands.dataset.restore.RestoreDatasetCommand"
+
+_UUID = UUID("11111111-2222-3333-4444-555555555555")
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool's editorship gate calls the real security manager; default
+ # it to a no-op (caller is an editor) so unrelated tests keep passing.
+ # The disclosure tests below re-patch it to raise.
+ with patch("superset.security_manager.raise_for_editorship"):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(
+ dataset_id: int = 10,
+ table_name: str = "orders",
+ deleted: bool = True,
+) -> Mock:
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ dataset.uuid = _UUID
+ dataset.deleted_at = datetime(2026, 7, 1) if deleted else None
+ return dataset
+
+
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_not_found(mock_find: Mock, mcp_server: object) -> None:
+ mock_find.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_not_in_trash(
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Missing test docstring</b></div>
<div id="fix">
Org rule (BITO 12148) requires docstrings on every newly added test function, and sibling tests in this file (e.g. `test_restore_dataset_logical_duplicate`) include one. Add a brief docstring stating the scenario: a dataset with `deleted_at=None` must return `success=False`, `error_type='NotDeleted'`, and never reach `RestoreDatasetCommand`.
</div>
</div>
<small><i>Code Review Run #81edb1</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
##########
tests/unit_tests/mcp_service/dataset/tool/test_restore_dataset.py:
##########
@@ -0,0 +1,377 @@
+# 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 the restore_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from datetime import datetime
+from typing import Any
+from unittest.mock import Mock, patch
+from uuid import UUID
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_FIND = "superset.daos.dataset.DatasetDAO.find_by_id_or_uuid"
+_COMMAND = "superset.commands.dataset.restore.RestoreDatasetCommand"
+
+_UUID = UUID("11111111-2222-3333-4444-555555555555")
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool's editorship gate calls the real security manager; default
+ # it to a no-op (caller is an editor) so unrelated tests keep passing.
+ # The disclosure tests below re-patch it to raise.
+ with patch("superset.security_manager.raise_for_editorship"):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(
+ dataset_id: int = 10,
+ table_name: str = "orders",
+ deleted: bool = True,
+) -> Mock:
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ dataset.uuid = _UUID
+ dataset.deleted_at = datetime(2026, 7, 1) if deleted else None
+ return dataset
+
+
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_not_found(mock_find: Mock, mcp_server: object) -> None:
+ mock_find.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_not_in_trash(
+ mock_find: Mock, mcp_server: object
+) -> None:
+ mock_find.return_value = _mock_dataset(deleted=False)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotDeleted"
+ assert "not in trash" in (content["error"] or "").lower()
+
+
+@patch(_COMMAND)
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_success_by_numeric_id(
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Missing test docstring</b></div>
<div id="fix">
Org rule (BITO 12148) requires docstrings on every newly added test function; sibling tests in this file include them. Document the scenario: lookup by numeric ID restores via `RestoreDatasetCommand(str(dataset.uuid)).run()` and returns `restored_id`/`restored_name` with `permission_denied=False`.
</div>
</div>
<small><i>Code Review Run #81edb1</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
##########
tests/unit_tests/mcp_service/dataset/tool/test_restore_dataset.py:
##########
@@ -0,0 +1,377 @@
+# 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 the restore_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from datetime import datetime
+from typing import Any
+from unittest.mock import Mock, patch
+from uuid import UUID
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_FIND = "superset.daos.dataset.DatasetDAO.find_by_id_or_uuid"
+_COMMAND = "superset.commands.dataset.restore.RestoreDatasetCommand"
+
+_UUID = UUID("11111111-2222-3333-4444-555555555555")
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool's editorship gate calls the real security manager; default
+ # it to a no-op (caller is an editor) so unrelated tests keep passing.
+ # The disclosure tests below re-patch it to raise.
+ with patch("superset.security_manager.raise_for_editorship"):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(
+ dataset_id: int = 10,
+ table_name: str = "orders",
+ deleted: bool = True,
+) -> Mock:
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ dataset.uuid = _UUID
+ dataset.deleted_at = datetime(2026, 7, 1) if deleted else None
+ return dataset
+
+
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_not_found(mock_find: Mock, mcp_server: object) -> None:
+ mock_find.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_not_in_trash(
+ mock_find: Mock, mcp_server: object
+) -> None:
+ mock_find.return_value = _mock_dataset(deleted=False)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotDeleted"
+ assert "not in trash" in (content["error"] or "").lower()
+
+
+@patch(_COMMAND)
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_success_by_numeric_id(
+ mock_find: Mock, mock_command: Mock, mcp_server: object
+) -> None:
+ mock_find.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["restored_id"] == 10
+ assert "orders" in content["restored_name"]
+ assert content["permission_denied"] is False
+ mock_command.assert_called_once_with(str(_UUID))
+ mock_command.return_value.run.assert_called_once()
+
+
+@patch(_COMMAND)
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_success_by_uuid(
+ mock_find: Mock, mock_command: Mock, mcp_server: object
+) -> None:
+ mock_find.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": str(_UUID)}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["restored_id"] == 10
+ mock_find.assert_called_once_with(
+ str(_UUID), skip_base_filter=True, skip_visibility_filter=True
+ )
+ mock_command.assert_called_once_with(str(_UUID))
+
+
+@patch(_COMMAND)
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_permission_denied(
+ mock_find: Mock, mock_command: Mock, mcp_server: object
+) -> None:
+ from superset.commands.dataset.exceptions import DatasetForbiddenError
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Inline import in test</b></div>
<div id="fix">
BITO rule 12745 requires module-level imports unless a circular dependency is documented. None applies here: `superset/mcp_service/dataset/tool/restore_dataset.py` imports these exceptions at module level. Hoist `DatasetForbiddenError` to the top-level import block with the other `superset` imports.
</div>
</div>
<small><i>Code Review Run #81edb1</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
##########
tests/unit_tests/mcp_service/dataset/tool/test_restore_dataset.py:
##########
@@ -0,0 +1,377 @@
+# 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 the restore_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from datetime import datetime
+from typing import Any
+from unittest.mock import Mock, patch
+from uuid import UUID
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_FIND = "superset.daos.dataset.DatasetDAO.find_by_id_or_uuid"
+_COMMAND = "superset.commands.dataset.restore.RestoreDatasetCommand"
+
+_UUID = UUID("11111111-2222-3333-4444-555555555555")
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool's editorship gate calls the real security manager; default
+ # it to a no-op (caller is an editor) so unrelated tests keep passing.
+ # The disclosure tests below re-patch it to raise.
+ with patch("superset.security_manager.raise_for_editorship"):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(
+ dataset_id: int = 10,
+ table_name: str = "orders",
+ deleted: bool = True,
+) -> Mock:
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ dataset.uuid = _UUID
+ dataset.deleted_at = datetime(2026, 7, 1) if deleted else None
+ return dataset
+
+
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_not_found(mock_find: Mock, mcp_server: object) -> None:
+ mock_find.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_not_in_trash(
+ mock_find: Mock, mcp_server: object
+) -> None:
+ mock_find.return_value = _mock_dataset(deleted=False)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotDeleted"
+ assert "not in trash" in (content["error"] or "").lower()
+
+
+@patch(_COMMAND)
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_success_by_numeric_id(
+ mock_find: Mock, mock_command: Mock, mcp_server: object
+) -> None:
+ mock_find.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["restored_id"] == 10
+ assert "orders" in content["restored_name"]
+ assert content["permission_denied"] is False
+ mock_command.assert_called_once_with(str(_UUID))
+ mock_command.return_value.run.assert_called_once()
+
+
+@patch(_COMMAND)
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_success_by_uuid(
+ mock_find: Mock, mock_command: Mock, mcp_server: object
+) -> None:
+ mock_find.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": str(_UUID)}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["restored_id"] == 10
+ mock_find.assert_called_once_with(
+ str(_UUID), skip_base_filter=True, skip_visibility_filter=True
+ )
+ mock_command.assert_called_once_with(str(_UUID))
+
+
+@patch(_COMMAND)
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_permission_denied(
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Missing test docstring</b></div>
<div id="fix">
Org rule (BITO 12148) requires docstrings on every newly added test function; sibling tests in this file include them. Document the scenario: `DatasetForbiddenError` raised from `RestoreDatasetCommand.run` maps to `permission_denied=True` with a permission-worded error.
</div>
</div>
<small><i>Code Review Run #81edb1</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
##########
tests/unit_tests/mcp_service/dataset/tool/test_restore_dataset.py:
##########
@@ -0,0 +1,377 @@
+# 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 the restore_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from datetime import datetime
+from typing import Any
+from unittest.mock import Mock, patch
+from uuid import UUID
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_FIND = "superset.daos.dataset.DatasetDAO.find_by_id_or_uuid"
+_COMMAND = "superset.commands.dataset.restore.RestoreDatasetCommand"
+
+_UUID = UUID("11111111-2222-3333-4444-555555555555")
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool's editorship gate calls the real security manager; default
+ # it to a no-op (caller is an editor) so unrelated tests keep passing.
+ # The disclosure tests below re-patch it to raise.
+ with patch("superset.security_manager.raise_for_editorship"):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(
+ dataset_id: int = 10,
+ table_name: str = "orders",
+ deleted: bool = True,
+) -> Mock:
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ dataset.uuid = _UUID
+ dataset.deleted_at = datetime(2026, 7, 1) if deleted else None
+ return dataset
+
+
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_not_found(mock_find: Mock, mcp_server: object) -> None:
+ mock_find.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_not_in_trash(
+ mock_find: Mock, mcp_server: object
+) -> None:
+ mock_find.return_value = _mock_dataset(deleted=False)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotDeleted"
+ assert "not in trash" in (content["error"] or "").lower()
+
+
+@patch(_COMMAND)
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_success_by_numeric_id(
+ mock_find: Mock, mock_command: Mock, mcp_server: object
+) -> None:
+ mock_find.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["restored_id"] == 10
+ assert "orders" in content["restored_name"]
+ assert content["permission_denied"] is False
+ mock_command.assert_called_once_with(str(_UUID))
+ mock_command.return_value.run.assert_called_once()
+
+
+@patch(_COMMAND)
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_success_by_uuid(
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Missing test docstring</b></div>
<div id="fix">
Org rule (BITO 12148) requires docstrings on every newly added test function; sibling tests in this file include them. Document the scenario: a UUID-string identifier resolves through the unfiltered `DatasetDAO.find_by_id_or_uuid` lookup and restores the same dataset.
</div>
</div>
<small><i>Code Review Run #81edb1</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
##########
tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py:
##########
@@ -0,0 +1,336 @@
+# 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 the delete_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_RESOLVE = "superset.mcp_service.dataset.tool.delete_dataset.resolve_dataset"
+_COUNT = "superset.mcp_service.dataset.tool.delete_dataset._count_affected_objects"
+_RUN = "superset.commands.dataset.delete.DeleteDatasetCommand.run"
+_VALIDATE = "superset.commands.dataset.delete.DeleteDatasetCommand.validate"
+_FLAG = "superset.mcp_service.dataset.tool.delete_dataset.is_feature_enabled"
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ """Provide the FastMCP app instance under test."""
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ """Authenticate every tool call as a mock admin user."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool validates the command (lookup + editorship) before counting
+ # dependents; default it to a pass so tests that patch run() alone keep
+ # working. The permission test re-patches it to raise.
+ with patch(_VALIDATE):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(dataset_id: int = 10, table_name: str = "orders") -> Mock:
+ """Build a minimal dataset stand-in with the attributes the tool reads."""
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ return dataset
+
+
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_not_found(mock_resolve: Mock, mcp_server: object) -> None:
+ mock_resolve.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_success(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["deleted_id"] == 10
+ assert "orders" in content["deleted_name"]
+ assert content["permission_denied"] is False
+ assert content["affected_chart_count"] == 0
+ mock_run.assert_called_once()
+ mock_count.assert_called_once_with(10)
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Missing test docstring</b></div>
<div id="fix">
`test_delete_dataset_success` is a new test function with no docstring, unlike `test_delete_dataset_not_found` and the other siblings in this file which document intent. BITO.md adaptive rule 12148 requires docstrings on every newly added test function; adding one keeps this file consistent and makes the covered scenario (happy path: resolve, run, count) explicit.
</div>
</div>
<small><i>Code Review Run #81edb1</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
##########
tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py:
##########
@@ -0,0 +1,336 @@
+# 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 the delete_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_RESOLVE = "superset.mcp_service.dataset.tool.delete_dataset.resolve_dataset"
+_COUNT = "superset.mcp_service.dataset.tool.delete_dataset._count_affected_objects"
+_RUN = "superset.commands.dataset.delete.DeleteDatasetCommand.run"
+_VALIDATE = "superset.commands.dataset.delete.DeleteDatasetCommand.validate"
+_FLAG = "superset.mcp_service.dataset.tool.delete_dataset.is_feature_enabled"
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ """Provide the FastMCP app instance under test."""
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ """Authenticate every tool call as a mock admin user."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool validates the command (lookup + editorship) before counting
+ # dependents; default it to a pass so tests that patch run() alone keep
+ # working. The permission test re-patches it to raise.
+ with patch(_VALIDATE):
+ mock_user = Mock()
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Missing fixture local annotations</b></div>
<div id="fix">
`mock_auth` assigns `mock_user = Mock()` (line 53) and `_mock_dataset` assigns `dataset = Mock()` (line 62) without annotations, despite the fixtures themselves being fully annotated. Repo rule 13153 covers these locals; add `: Mock` to both.
</div>
</div>
<small><i>Code Review Run #81edb1</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
##########
tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py:
##########
@@ -0,0 +1,336 @@
+# 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 the delete_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_RESOLVE = "superset.mcp_service.dataset.tool.delete_dataset.resolve_dataset"
+_COUNT = "superset.mcp_service.dataset.tool.delete_dataset._count_affected_objects"
+_RUN = "superset.commands.dataset.delete.DeleteDatasetCommand.run"
+_VALIDATE = "superset.commands.dataset.delete.DeleteDatasetCommand.validate"
+_FLAG = "superset.mcp_service.dataset.tool.delete_dataset.is_feature_enabled"
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ """Provide the FastMCP app instance under test."""
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ """Authenticate every tool call as a mock admin user."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool validates the command (lookup + editorship) before counting
+ # dependents; default it to a pass so tests that patch run() alone keep
+ # working. The permission test re-patches it to raise.
+ with patch(_VALIDATE):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(dataset_id: int = 10, table_name: str = "orders") -> Mock:
+ """Build a minimal dataset stand-in with the attributes the tool reads."""
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ return dataset
+
+
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_not_found(mock_resolve: Mock, mcp_server: object) -> None:
+ mock_resolve.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_success(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["deleted_id"] == 10
+ assert "orders" in content["deleted_name"]
+ assert content["permission_denied"] is False
+ assert content["affected_chart_count"] == 0
+ mock_run.assert_called_once()
+ mock_count.assert_called_once_with(10)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_by_uuid(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ uuid = "11111111-2222-3333-4444-555555555555"
+ mock_resolve.return_value = _mock_dataset(dataset_id=10)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": uuid}}
+ )
+
+ assert result.structured_content["success"] is True
+ mock_resolve.assert_called_once_with(uuid)
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Missing test docstring</b></div>
<div id="fix">
`test_delete_dataset_by_uuid` is a new test function with no docstring, unlike its siblings (`test_delete_dataset_not_found`, `test_count_affected_objects_only_counts_accessible`, `test_delete_dataset_rejects_boolean_identifier`) which document intent. BITO.md adaptive rule 12148 requires docstrings on newly added test functions; one line stating the UUID pass-through scenario keeps the file uniform.
</div>
</div>
<div id="suggestion">
<div id="issue"><b>Untyped local variable</b></div>
<div id="fix">
The local `uuid` at line 116 is the only unannotated local in the changed tests; siblings (`content`, `result`, `mock_user`, `security_manager`) are all annotated per BITO.md adaptive rule 13153. Adding `: str` keeps the file consistent with the repo's strict test-typing convention.
</div>
</div>
<small><i>Code Review Run #81edb1</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
##########
tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py:
##########
@@ -0,0 +1,336 @@
+# 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 the delete_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_RESOLVE = "superset.mcp_service.dataset.tool.delete_dataset.resolve_dataset"
+_COUNT = "superset.mcp_service.dataset.tool.delete_dataset._count_affected_objects"
+_RUN = "superset.commands.dataset.delete.DeleteDatasetCommand.run"
+_VALIDATE = "superset.commands.dataset.delete.DeleteDatasetCommand.validate"
+_FLAG = "superset.mcp_service.dataset.tool.delete_dataset.is_feature_enabled"
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ """Provide the FastMCP app instance under test."""
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ """Authenticate every tool call as a mock admin user."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool validates the command (lookup + editorship) before counting
+ # dependents; default it to a pass so tests that patch run() alone keep
+ # working. The permission test re-patches it to raise.
+ with patch(_VALIDATE):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(dataset_id: int = 10, table_name: str = "orders") -> Mock:
+ """Build a minimal dataset stand-in with the attributes the tool reads."""
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ return dataset
+
+
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_not_found(mock_resolve: Mock, mcp_server: object) -> None:
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Test missing docstring</b></div>
<div id="fix">
`test_delete_dataset_not_found` (line 70) is the only test function in this new file without a docstring; every sibling test documents its scenario. Repo rule 12148 requires docstrings on all new test functions. Add a one-line docstring describing the not-found path.
</div>
</div>
<small><i>Code Review Run #81edb1</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
##########
tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py:
##########
@@ -0,0 +1,336 @@
+# 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 the delete_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_RESOLVE = "superset.mcp_service.dataset.tool.delete_dataset.resolve_dataset"
+_COUNT = "superset.mcp_service.dataset.tool.delete_dataset._count_affected_objects"
+_RUN = "superset.commands.dataset.delete.DeleteDatasetCommand.run"
+_VALIDATE = "superset.commands.dataset.delete.DeleteDatasetCommand.validate"
+_FLAG = "superset.mcp_service.dataset.tool.delete_dataset.is_feature_enabled"
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ """Provide the FastMCP app instance under test."""
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ """Authenticate every tool call as a mock admin user."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool validates the command (lookup + editorship) before counting
+ # dependents; default it to a pass so tests that patch run() alone keep
+ # working. The permission test re-patches it to raise.
+ with patch(_VALIDATE):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(dataset_id: int = 10, table_name: str = "orders") -> Mock:
+ """Build a minimal dataset stand-in with the attributes the tool reads."""
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ return dataset
+
+
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_not_found(mock_resolve: Mock, mcp_server: object) -> None:
+ mock_resolve.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_success(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["deleted_id"] == 10
+ assert "orders" in content["deleted_name"]
+ assert content["permission_denied"] is False
+ assert content["affected_chart_count"] == 0
+ mock_run.assert_called_once()
+ mock_count.assert_called_once_with(10)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_by_uuid(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ uuid = "11111111-2222-3333-4444-555555555555"
+ mock_resolve.return_value = _mock_dataset(dataset_id=10)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": uuid}}
+ )
+
+ assert result.structured_content["success"] is True
+ mock_resolve.assert_called_once_with(uuid)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_FLAG)
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Untyped lambda side_effect</b></div>
<div id="fix">
`test_delete_dataset_soft_delete_reports_restorable` sets `mock_flag.side_effect = lambda flag: flag == "SOFT_DELETE"` (line 129). Repo rule 13350 requires typed helper functions over untyped lambdas. Extract a typed module-level helper and assign it.
</div>
</div>
<small><i>Code Review Run #81edb1</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
##########
tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py:
##########
@@ -0,0 +1,336 @@
+# 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 the delete_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_RESOLVE = "superset.mcp_service.dataset.tool.delete_dataset.resolve_dataset"
+_COUNT = "superset.mcp_service.dataset.tool.delete_dataset._count_affected_objects"
+_RUN = "superset.commands.dataset.delete.DeleteDatasetCommand.run"
+_VALIDATE = "superset.commands.dataset.delete.DeleteDatasetCommand.validate"
+_FLAG = "superset.mcp_service.dataset.tool.delete_dataset.is_feature_enabled"
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ """Provide the FastMCP app instance under test."""
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ """Authenticate every tool call as a mock admin user."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool validates the command (lookup + editorship) before counting
+ # dependents; default it to a pass so tests that patch run() alone keep
+ # working. The permission test re-patches it to raise.
+ with patch(_VALIDATE):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(dataset_id: int = 10, table_name: str = "orders") -> Mock:
+ """Build a minimal dataset stand-in with the attributes the tool reads."""
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ return dataset
+
+
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_not_found(mock_resolve: Mock, mcp_server: object) -> None:
+ mock_resolve.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_success(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["deleted_id"] == 10
+ assert "orders" in content["deleted_name"]
+ assert content["permission_denied"] is False
+ assert content["affected_chart_count"] == 0
+ mock_run.assert_called_once()
+ mock_count.assert_called_once_with(10)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_by_uuid(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ uuid = "11111111-2222-3333-4444-555555555555"
+ mock_resolve.return_value = _mock_dataset(dataset_id=10)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": uuid}}
+ )
+
+ assert result.structured_content["success"] is True
+ mock_resolve.assert_called_once_with(uuid)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_FLAG)
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_soft_delete_reports_restorable(
+ mock_resolve: Mock,
+ mock_run: Mock,
+ mock_flag: Mock,
+ mock_count: Mock,
+ mcp_server: object,
+) -> None:
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+ mock_flag.side_effect = lambda flag: flag == "SOFT_DELETE"
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["soft_deleted"] is True
+ assert "restor" in (content["message"] or "").lower()
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_FLAG)
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_hard_delete_reports_permanent(
+ mock_resolve: Mock,
+ mock_run: Mock,
+ mock_flag: Mock,
+ mock_count: Mock,
+ mcp_server: object,
+) -> None:
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+ mock_flag.return_value = False
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["soft_deleted"] is False
+ assert "permanent" in (content["message"] or "").lower()
+
+
+@patch(_COUNT, return_value=(3, 2))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_reports_affected_charts(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["affected_chart_count"] == 3
+ assert content["affected_dashboard_count"] == 2
+ assert "3 chart(s)" in (content["message"] or "")
+
+
+def test_count_affected_objects_only_counts_accessible() -> None:
+ """Counts must not disclose charts/dashboards the caller cannot access."""
+ from superset.mcp_service.dataset.tool.delete_dataset import (
+ _count_affected_objects,
+ )
+
+ visible_chart, hidden_chart = Mock(), Mock()
+ visible_dashboard, hidden_dashboard = Mock(), Mock()
+ security_manager = Mock()
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Missing mock type annotations</b></div>
<div id="fix">
`test_count_affected_objects_only_counts_accessible` assigns `visible_chart, hidden_chart = Mock(), Mock()` (and `visible_dashboard`, `security_manager`) with no annotations. Repo rule 13153 requires explicit annotations for test-file locals including mocks. Add `: Mock` to each.
</div>
</div>
<small><i>Code Review Run #81edb1</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
##########
tests/unit_tests/mcp_service/test_rbac_tool_enforcement.py:
##########
@@ -110,6 +110,24 @@
"write",
"Dataset",
),
+ (
+ "update_dataset",
+ {"dataset_id": 1, "description": "denied"},
+ "write",
+ "Dataset",
+ ),
+ (
+ "delete_dataset",
+ {"identifier": 1},
+ "write",
+ "Dataset",
+ ),
+ (
+ "restore_dataset",
+ {"identifier": 1},
+ "write",
+ "Dataset",
+ ),
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>RBAC permission mismatch</b></div>
<div id="fix">
These tests assert `can_access("can_write", "Dataset")`, but `delete_dataset`/`restore_dataset` declare only `class_permission_name="Dataset"` with no `method_permission_name`, so `mcp_auth_hook` defaults to `can_read` (auth.py:397). The `assert_called_with("can_write", ...)` will fail against the current tools. Add `method_permission_name="write"` to both tool decorators, or align the test expectation.
</div>
</div>
<small><i>Code Review Run #81edb1</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
##########
superset/mcp_service/dataset/tool/update_dataset.py:
##########
@@ -0,0 +1,320 @@
+# 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.
+
+"""
+MCP tool: update_dataset
+"""
+
+import logging
+from typing import Any
+
+from fastmcp import Context
+from sqlalchemy.exc import SQLAlchemyError
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset import security_manager
+from superset.exceptions import SupersetException
+from superset.extensions import event_logger
+from superset.mcp_service.dataset.schemas import (
+ UpdateDatasetRequest,
+ UpdateDatasetResponse,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _column_names(dataset: Any) -> set[str]:
+ return {column.column_name for column in dataset.columns}
+
+
+def _sync_error_message(ex: Exception) -> str:
+ # Raw SQLAlchemy text can leak SQL or connection details; Superset
+ # exception messages are user-facing by design.
+ if isinstance(ex, SQLAlchemyError):
+ return "a database error occurred"
+ return str(ex)
+
+
+@tool(
+ tags=["mutate"],
+ class_permission_name="Dataset",
+ method_permission_name="write",
+ annotations=ToolAnnotations(
+ title="Update dataset",
+ readOnlyHint=False,
+ # Rewriting a virtual dataset's SQL or re-syncing its columns changes
+ # what every chart built on it queries — non-additive, like
+ # update_chart.
+ destructiveHint=True,
+ idempotentHint=False,
+ openWorldHint=False,
+ ),
+)
+async def update_dataset( # noqa: C901
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Overloaded tool function</b></div>
<div id="fix">
`update_dataset` is ~250 lines with `# noqa: C901`, combining identifier resolution, editorship enforcement, sql-vs-physical validation, sync_columns computation, main_dttm_col validation/application, column re-sync, and response building. Multiple unrelated responsibilities in one unit (dim 14) make it hard to test and maintain.
</div>
</div>
<small><i>Code Review Run #81edb1</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
##########
superset/mcp_service/dataset/schemas.py:
##########
@@ -718,6 +739,246 @@ class UpdateDatasetMetricResponse(BaseModel):
)
+class DeleteDatasetRequest(BaseModel):
+ """Request schema for delete_dataset."""
+
+ identifier: int | str = Field(
+ ...,
+ description=(
+ "Dataset identifier - numeric ID or UUID string (NOT the table name)."
+ ),
+ )
+
+ @field_validator("identifier", mode="before")
+ @classmethod
+ def reject_bool_identifier(cls, value: object) -> object:
+ """bool is a subclass of int, so identifier=true would coerce to
+ dataset ID 1 and delete the wrong object; reject it outright."""
+ if isinstance(value, bool):
+ raise ValueError("identifier must be an integer ID or UUID string")
+ return value
+
+
+class DeleteDatasetResponse(BaseModel):
+ """Result of a delete_dataset operation."""
+
+ success: bool = Field(description="Whether the dataset was deleted")
+ deleted_id: int | None = Field(None, description="ID of the deleted dataset")
+ deleted_name: str | None = Field(
+ None, description="Table name of the deleted dataset"
+ )
+ soft_deleted: bool = Field(
+ False,
+ description=(
+ "True when the dataset was soft-deleted (moved to trash, because the "
+ "SOFT_DELETE feature flag is enabled) and can be restored by an "
+ "owner or Admin. False means the delete was permanent."
+ ),
+ )
+ affected_chart_count: int = Field(
+ 0,
+ description=(
+ "Number of charts (visible to the caller) built on this dataset. "
+ "They stop working while the dataset is deleted."
+ ),
+ )
+ affected_dashboard_count: int = Field(
+ 0,
+ description=(
+ "Number of dashboards (visible to the caller) containing those charts."
+ ),
+ )
+ message: str | None = Field(None, description="Human-readable outcome message")
+ error: str | None = Field(None, description="Error message if the delete failed")
+ error_type: str | None = Field(None, description="Type of error if failed")
+ permission_denied: bool = Field(
+ False,
+ description=(
+ "True when the caller lacks permission to delete the dataset (do not "
+ "retry; ask the user)."
+ ),
+ )
+
+
+class RestoreDatasetRequest(BaseModel):
+ """Request schema for restore_dataset."""
+
+ identifier: int | str = Field(
+ ...,
+ description=(
+ "Dataset identifier - numeric ID or UUID string (NOT the table name)."
+ ),
+ )
+
+ @field_validator("identifier", mode="before")
+ @classmethod
+ def reject_bool_identifier(cls, value: object) -> object:
+ """bool is a subclass of int, so identifier=true would coerce to
+ dataset ID 1 and target the wrong object; reject it outright."""
+ if isinstance(value, bool):
+ raise ValueError("identifier must be an integer ID or UUID string")
+ return value
+
+
+class RestoreDatasetResponse(BaseModel):
+ """Result of a restore_dataset operation."""
+
+ success: bool = Field(description="Whether the dataset was restored from trash")
+ restored_id: int | None = Field(None, description="ID of the restored dataset")
+ restored_name: str | None = Field(
+ None, description="Table name of the restored dataset"
+ )
+ message: str | None = Field(None, description="Human-readable outcome message")
+ error: str | None = Field(None, description="Error message if the restore failed")
+ error_type: str | None = Field(None, description="Type of error if failed")
+ permission_denied: bool = Field(
+ False,
+ description=(
+ "True when the caller lacks permission to restore the dataset (do not "
+ "retry; ask the user)."
+ ),
+ )
+
+
+UPDATABLE_DATASET_FIELDS: frozenset[str] = frozenset(
+ {
+ "table_name",
+ "sql",
+ "description",
+ "main_dttm_col",
+ "cache_timeout",
+ }
+)
+
+
+class UpdateDatasetRequest(BaseModel):
+ """Request schema for update_dataset."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ dataset_id: int | str = Field(
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Inconsistent identifier naming</b></div>
<div id="fix">
Related request schemas use inconsistent identifiers: `DeleteDatasetRequest.identifier` and `RestoreDatasetRequest.identifier` vs `UpdateDatasetRequest.dataset_id`. Equivalent operations should use a consistent field name to reduce cognitive load for MCP callers.
</div>
</div>
<small><i>Code Review Run #81edb1</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "tien238lnd (via GitHub)" <gi...@apache.org>.
tien238lnd commented on code in PR #44337:
URL: https://github.com/apache/superset/pull/44337#discussion_r4101779659
##########
tests/unit_tests/mcp_service/dataset/tool/test_restore_dataset.py:
##########
@@ -0,0 +1,377 @@
+# 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 the restore_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from datetime import datetime
+from typing import Any
+from unittest.mock import Mock, patch
+from uuid import UUID
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_FIND = "superset.daos.dataset.DatasetDAO.find_by_id_or_uuid"
+_COMMAND = "superset.commands.dataset.restore.RestoreDatasetCommand"
+
+_UUID = UUID("11111111-2222-3333-4444-555555555555")
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool's editorship gate calls the real security manager; default
+ # it to a no-op (caller is an editor) so unrelated tests keep passing.
+ # The disclosure tests below re-patch it to raise.
+ with patch("superset.security_manager.raise_for_editorship"):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(
+ dataset_id: int = 10,
+ table_name: str = "orders",
+ deleted: bool = True,
+) -> Mock:
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ dataset.uuid = _UUID
+ dataset.deleted_at = datetime(2026, 7, 1) if deleted else None
+ return dataset
+
+
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_not_found(mock_find: Mock, mcp_server: object) -> None:
+ mock_find.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_not_in_trash(
Review Comment:
Already in place: the docstrings landed in a7739e4aa9/9ded2f2adc, and every test in these two files states its scenario at this head.
_🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
##########
tests/unit_tests/mcp_service/dataset/tool/test_restore_dataset.py:
##########
@@ -0,0 +1,377 @@
+# 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 the restore_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from datetime import datetime
+from typing import Any
+from unittest.mock import Mock, patch
+from uuid import UUID
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_FIND = "superset.daos.dataset.DatasetDAO.find_by_id_or_uuid"
+_COMMAND = "superset.commands.dataset.restore.RestoreDatasetCommand"
+
+_UUID = UUID("11111111-2222-3333-4444-555555555555")
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool's editorship gate calls the real security manager; default
+ # it to a no-op (caller is an editor) so unrelated tests keep passing.
+ # The disclosure tests below re-patch it to raise.
+ with patch("superset.security_manager.raise_for_editorship"):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(
+ dataset_id: int = 10,
+ table_name: str = "orders",
+ deleted: bool = True,
+) -> Mock:
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ dataset.uuid = _UUID
+ dataset.deleted_at = datetime(2026, 7, 1) if deleted else None
+ return dataset
+
+
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_not_found(mock_find: Mock, mcp_server: object) -> None:
+ mock_find.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_not_in_trash(
+ mock_find: Mock, mcp_server: object
+) -> None:
+ mock_find.return_value = _mock_dataset(deleted=False)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotDeleted"
+ assert "not in trash" in (content["error"] or "").lower()
+
+
+@patch(_COMMAND)
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_success_by_numeric_id(
Review Comment:
Already in place: the docstrings landed in a7739e4aa9/9ded2f2adc, and every test in these two files states its scenario at this head.
_🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
##########
tests/unit_tests/mcp_service/dataset/tool/test_restore_dataset.py:
##########
@@ -0,0 +1,377 @@
+# 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 the restore_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from datetime import datetime
+from typing import Any
+from unittest.mock import Mock, patch
+from uuid import UUID
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_FIND = "superset.daos.dataset.DatasetDAO.find_by_id_or_uuid"
+_COMMAND = "superset.commands.dataset.restore.RestoreDatasetCommand"
+
+_UUID = UUID("11111111-2222-3333-4444-555555555555")
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool's editorship gate calls the real security manager; default
+ # it to a no-op (caller is an editor) so unrelated tests keep passing.
+ # The disclosure tests below re-patch it to raise.
+ with patch("superset.security_manager.raise_for_editorship"):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(
+ dataset_id: int = 10,
+ table_name: str = "orders",
+ deleted: bool = True,
+) -> Mock:
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ dataset.uuid = _UUID
+ dataset.deleted_at = datetime(2026, 7, 1) if deleted else None
+ return dataset
+
+
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_not_found(mock_find: Mock, mcp_server: object) -> None:
+ mock_find.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_not_in_trash(
+ mock_find: Mock, mcp_server: object
+) -> None:
+ mock_find.return_value = _mock_dataset(deleted=False)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotDeleted"
+ assert "not in trash" in (content["error"] or "").lower()
+
+
+@patch(_COMMAND)
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_success_by_numeric_id(
+ mock_find: Mock, mock_command: Mock, mcp_server: object
+) -> None:
+ mock_find.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["restored_id"] == 10
+ assert "orders" in content["restored_name"]
+ assert content["permission_denied"] is False
+ mock_command.assert_called_once_with(str(_UUID))
+ mock_command.return_value.run.assert_called_once()
+
+
+@patch(_COMMAND)
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_success_by_uuid(
Review Comment:
Already in place: the docstrings landed in a7739e4aa9/9ded2f2adc, and every test in these two files states its scenario at this head.
_🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
##########
tests/unit_tests/mcp_service/dataset/tool/test_restore_dataset.py:
##########
@@ -0,0 +1,377 @@
+# 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 the restore_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from datetime import datetime
+from typing import Any
+from unittest.mock import Mock, patch
+from uuid import UUID
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_FIND = "superset.daos.dataset.DatasetDAO.find_by_id_or_uuid"
+_COMMAND = "superset.commands.dataset.restore.RestoreDatasetCommand"
+
+_UUID = UUID("11111111-2222-3333-4444-555555555555")
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool's editorship gate calls the real security manager; default
+ # it to a no-op (caller is an editor) so unrelated tests keep passing.
+ # The disclosure tests below re-patch it to raise.
+ with patch("superset.security_manager.raise_for_editorship"):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(
+ dataset_id: int = 10,
+ table_name: str = "orders",
+ deleted: bool = True,
+) -> Mock:
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ dataset.uuid = _UUID
+ dataset.deleted_at = datetime(2026, 7, 1) if deleted else None
+ return dataset
+
+
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_not_found(mock_find: Mock, mcp_server: object) -> None:
+ mock_find.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_not_in_trash(
+ mock_find: Mock, mcp_server: object
+) -> None:
+ mock_find.return_value = _mock_dataset(deleted=False)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotDeleted"
+ assert "not in trash" in (content["error"] or "").lower()
+
+
+@patch(_COMMAND)
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_success_by_numeric_id(
+ mock_find: Mock, mock_command: Mock, mcp_server: object
+) -> None:
+ mock_find.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["restored_id"] == 10
+ assert "orders" in content["restored_name"]
+ assert content["permission_denied"] is False
+ mock_command.assert_called_once_with(str(_UUID))
+ mock_command.return_value.run.assert_called_once()
+
+
+@patch(_COMMAND)
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_success_by_uuid(
+ mock_find: Mock, mock_command: Mock, mcp_server: object
+) -> None:
+ mock_find.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": str(_UUID)}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["restored_id"] == 10
+ mock_find.assert_called_once_with(
+ str(_UUID), skip_base_filter=True, skip_visibility_filter=True
+ )
+ mock_command.assert_called_once_with(str(_UUID))
+
+
+@patch(_COMMAND)
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_permission_denied(
Review Comment:
Already in place: the docstrings landed in a7739e4aa9/9ded2f2adc, and every test in these two files states its scenario at this head.
_🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
##########
tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py:
##########
@@ -0,0 +1,336 @@
+# 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 the delete_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_RESOLVE = "superset.mcp_service.dataset.tool.delete_dataset.resolve_dataset"
+_COUNT = "superset.mcp_service.dataset.tool.delete_dataset._count_affected_objects"
+_RUN = "superset.commands.dataset.delete.DeleteDatasetCommand.run"
+_VALIDATE = "superset.commands.dataset.delete.DeleteDatasetCommand.validate"
+_FLAG = "superset.mcp_service.dataset.tool.delete_dataset.is_feature_enabled"
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ """Provide the FastMCP app instance under test."""
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ """Authenticate every tool call as a mock admin user."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool validates the command (lookup + editorship) before counting
+ # dependents; default it to a pass so tests that patch run() alone keep
+ # working. The permission test re-patches it to raise.
+ with patch(_VALIDATE):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(dataset_id: int = 10, table_name: str = "orders") -> Mock:
+ """Build a minimal dataset stand-in with the attributes the tool reads."""
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ return dataset
+
+
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_not_found(mock_resolve: Mock, mcp_server: object) -> None:
+ mock_resolve.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_success(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["deleted_id"] == 10
+ assert "orders" in content["deleted_name"]
+ assert content["permission_denied"] is False
+ assert content["affected_chart_count"] == 0
+ mock_run.assert_called_once()
+ mock_count.assert_called_once_with(10)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_by_uuid(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ uuid = "11111111-2222-3333-4444-555555555555"
+ mock_resolve.return_value = _mock_dataset(dataset_id=10)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": uuid}}
+ )
+
+ assert result.structured_content["success"] is True
+ mock_resolve.assert_called_once_with(uuid)
Review Comment:
Already in place: the docstrings landed in a7739e4aa9/9ded2f2adc, and every test in these two files states its scenario at this head.
_🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
##########
tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py:
##########
@@ -0,0 +1,336 @@
+# 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 the delete_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_RESOLVE = "superset.mcp_service.dataset.tool.delete_dataset.resolve_dataset"
+_COUNT = "superset.mcp_service.dataset.tool.delete_dataset._count_affected_objects"
+_RUN = "superset.commands.dataset.delete.DeleteDatasetCommand.run"
+_VALIDATE = "superset.commands.dataset.delete.DeleteDatasetCommand.validate"
+_FLAG = "superset.mcp_service.dataset.tool.delete_dataset.is_feature_enabled"
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ """Provide the FastMCP app instance under test."""
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ """Authenticate every tool call as a mock admin user."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool validates the command (lookup + editorship) before counting
+ # dependents; default it to a pass so tests that patch run() alone keep
+ # working. The permission test re-patches it to raise.
+ with patch(_VALIDATE):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(dataset_id: int = 10, table_name: str = "orders") -> Mock:
+ """Build a minimal dataset stand-in with the attributes the tool reads."""
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ return dataset
+
+
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_not_found(mock_resolve: Mock, mcp_server: object) -> None:
Review Comment:
Already in place: the docstrings landed in a7739e4aa9/9ded2f2adc, and every test in these two files states its scenario at this head.
_🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
##########
tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py:
##########
@@ -0,0 +1,336 @@
+# 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 the delete_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_RESOLVE = "superset.mcp_service.dataset.tool.delete_dataset.resolve_dataset"
+_COUNT = "superset.mcp_service.dataset.tool.delete_dataset._count_affected_objects"
+_RUN = "superset.commands.dataset.delete.DeleteDatasetCommand.run"
+_VALIDATE = "superset.commands.dataset.delete.DeleteDatasetCommand.validate"
+_FLAG = "superset.mcp_service.dataset.tool.delete_dataset.is_feature_enabled"
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ """Provide the FastMCP app instance under test."""
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ """Authenticate every tool call as a mock admin user."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool validates the command (lookup + editorship) before counting
+ # dependents; default it to a pass so tests that patch run() alone keep
+ # working. The permission test re-patches it to raise.
+ with patch(_VALIDATE):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(dataset_id: int = 10, table_name: str = "orders") -> Mock:
+ """Build a minimal dataset stand-in with the attributes the tool reads."""
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ return dataset
+
+
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_not_found(mock_resolve: Mock, mcp_server: object) -> None:
+ mock_resolve.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_success(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["deleted_id"] == 10
+ assert "orders" in content["deleted_name"]
+ assert content["permission_denied"] is False
+ assert content["affected_chart_count"] == 0
+ mock_run.assert_called_once()
+ mock_count.assert_called_once_with(10)
Review Comment:
Already in place: the docstrings landed in a7739e4aa9/9ded2f2adc, and every test in these two files states its scenario at this head.
_🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "tien238lnd (via GitHub)" <gi...@apache.org>.
tien238lnd commented on PR #44337:
URL: https://github.com/apache/superset/pull/44337#issuecomment-5862211572
Merged `master` in at 4131c1c23. Every conflict came from the same place: upstream's new `create_dataset_metric`/`delete_dataset_metric` tools and this PR's `update_dataset`/`delete_dataset`/`restore_dataset` land in the same lists, so all five are kept everywhere — the tool descriptions and write-permission list in `app.py`, the tool imports and `__all__`, `mcp_config.py`, `COMMITTED_WRITE_SPECS`, and the two invariant test tables.
One thing git merged cleanly but wrongly: in `dataset/tool/__init__.py` the `__all__` entry for `delete_dataset_metric` landed between `create_dataset_metric` and `create_virtual_dataset`. Reordered alphabetically.
Locally: 358 passed in `tests/unit_tests/mcp_service/dataset` and 36 in the registration plus rbac suites, with only `test_query_dataset_reexecutes_across_rollover` failing, which fails the same way on master here.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "tien238lnd (via GitHub)" <gi...@apache.org>.
tien238lnd commented on PR #44337:
URL: https://github.com/apache/superset/pull/44337#issuecomment-5756711821
Added 9ded2f2adc:
- The bare dataset mocks in `test_dataset_tools.py` set `deleted_at = None` (Amin's open thread).
- `update_dataset` returns a structured error when the dataset lookup hits a database error, as `delete_dataset` and `restore_dataset` already do (Bito).
- The delete and restore tests that lacked docstrings have one, like the rest of those files (Bito).
Bito's other suggestions (annotating mock locals, a typed helper instead of a lambda `side_effect`, hoisting imports out of tests, renaming `identifier`, splitting `update_dataset`) I left as they are: the tests follow the style of the existing chart tool tests, which also import inside tests, and `identifier` matches `delete_chart` and `restore_chart`.
`pytest tests/unit_tests/mcp_service/dataset tests/unit_tests/mcp_service/test_mcp_tool_registration.py tests/unit_tests/mcp_service/test_rbac_tool_enforcement.py tests/unit_tests/mcp_service/test_mcp_caching.py tests/unit_tests/commands/dataset` on this head: 417 passed, plus the 2 `test_query_dataset.py` failures that also occur on master in my environment.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "tien238lnd (via GitHub)" <gi...@apache.org>.
tien238lnd commented on code in PR #44337:
URL: https://github.com/apache/superset/pull/44337#discussion_r4059931528
##########
tests/unit_tests/mcp_service/dataset/tool/test_dataset_tools.py:
##########
@@ -98,6 +98,7 @@ def create_mock_dataset(
dataset.template_params = {}
dataset.extra = {}
dataset.uuid = f"test-dataset-uuid-{dataset_id}"
+ dataset.deleted_at = None
Review Comment:
Confirmed, and my earlier reply on the Copilot thread was wrong: those tests pass, but the datasets read as trashed. Fixed in 9ded2f2adc: all 13 bare `dataset = MagicMock()` sites now set `deleted_at = None`. The fourteenth `MagicMock()` in the file is the shared fixture, which already did.
##########
tests/unit_tests/mcp_service/dataset/tool/test_dataset_tools.py:
##########
@@ -97,6 +97,7 @@ def create_mock_dataset(
dataset.template_params = {}
dataset.extra = {}
dataset.uuid = f"test-dataset-uuid-{dataset_id}"
+ dataset.deleted_at = None
Review Comment:
Correction to my reply above: the tests do pass, but as Amin measured in the thread on line 101, `deleted_at` then reads `MagicMock.__int__` and the datasets come out as trashed at `1970-01-01T00:00:01Z`. Fixed in 9ded2f2adc by setting `deleted_at = None` on each bare mock.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "aminghadersohi (via GitHub)" <gi...@apache.org>.
aminghadersohi commented on code in PR #44337:
URL: https://github.com/apache/superset/pull/44337#discussion_r4035100146
##########
tests/unit_tests/mcp_service/dataset/tool/test_dataset_tools.py:
##########
@@ -98,6 +98,7 @@ def create_mock_dataset(
dataset.template_params = {}
dataset.extra = {}
dataset.uuid = f"test-dataset-uuid-{dataset_id}"
+ dataset.deleted_at = None
Review Comment:
The 14 bare `dataset = MagicMock()` sites here bypass this fixture and still omit `deleted_at`. Measured: `serialize_dataset_object` then returns `1970-01-01T00:00:01Z` (MagicMock `__int__`→1, coerced by `str | datetime | None`) — they read as trashed. Silent, not a ValidationError.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "codeant-ai-for-open-source[bot] (via GitHub)" <gi...@apache.org>.
codeant-ai-for-open-source[bot] commented on code in PR #44337:
URL: https://github.com/apache/superset/pull/44337#discussion_r4081223021
##########
superset/mcp_service/dataset/schemas.py:
##########
@@ -718,6 +739,237 @@ class UpdateDatasetMetricResponse(BaseModel):
)
+class DeleteDatasetRequest(BaseModel):
+ """Request schema for delete_dataset."""
+
+ identifier: int | str = Field(
+ ...,
+ description=(
+ "Dataset identifier - numeric ID or UUID string (NOT the table name)."
+ ),
+ )
+
+ @field_validator("identifier", mode="before")
+ @classmethod
+ def reject_bool_identifier(cls, value: object) -> object:
+ """bool is a subclass of int, so identifier=true would coerce to
+ dataset ID 1 and delete the wrong object; reject it outright."""
+ if isinstance(value, bool):
+ raise ValueError("identifier must be an integer ID or UUID string")
+ return value
+
+
+class DeleteDatasetResponse(BaseModel):
+ """Result of a delete_dataset operation."""
+
+ success: bool = Field(description="Whether the dataset was deleted")
+ deleted_id: int | None = Field(None, description="ID of the deleted dataset")
+ deleted_name: str | None = Field(
+ None, description="Table name of the deleted dataset"
+ )
+ soft_deleted: bool = Field(
+ False,
+ description=(
+ "True when the dataset was soft-deleted (moved to trash, because the "
+ "SOFT_DELETE feature flag is enabled) and can be restored by an "
+ "owner or Admin. False means the delete was permanent."
+ ),
+ )
+ affected_chart_count: int = Field(
+ 0,
+ description=(
+ "Number of charts (visible to the caller) built on this dataset. "
+ "They stop working while the dataset is deleted."
+ ),
+ )
+ affected_dashboard_count: int = Field(
+ 0,
+ description=(
+ "Number of dashboards (visible to the caller) containing those charts."
+ ),
+ )
+ message: str | None = Field(None, description="Human-readable outcome message")
+ error: str | None = Field(None, description="Error message if the delete failed")
+ error_type: str | None = Field(None, description="Type of error if failed")
+ permission_denied: bool = Field(
+ False,
+ description=(
+ "True when the caller lacks permission to delete the dataset (do not "
+ "retry; ask the user)."
+ ),
+ )
+
+
+class RestoreDatasetRequest(BaseModel):
+ """Request schema for restore_dataset."""
+
+ identifier: int | str = Field(
+ ...,
+ description=(
+ "Dataset identifier - numeric ID or UUID string (NOT the table name)."
+ ),
+ )
+
+ @field_validator("identifier", mode="before")
+ @classmethod
+ def reject_bool_identifier(cls, value: object) -> object:
+ """bool is a subclass of int, so identifier=true would coerce to
+ dataset ID 1 and target the wrong object; reject it outright."""
+ if isinstance(value, bool):
+ raise ValueError("identifier must be an integer ID or UUID string")
+ return value
+
+
+class RestoreDatasetResponse(BaseModel):
+ """Result of a restore_dataset operation."""
+
+ success: bool = Field(description="Whether the dataset was restored from trash")
+ restored_id: int | None = Field(None, description="ID of the restored dataset")
+ restored_name: str | None = Field(
+ None, description="Table name of the restored dataset"
+ )
+ message: str | None = Field(None, description="Human-readable outcome message")
+ error: str | None = Field(None, description="Error message if the restore failed")
+ error_type: str | None = Field(None, description="Type of error if failed")
+ permission_denied: bool = Field(
+ False,
+ description=(
+ "True when the caller lacks permission to restore the dataset (do not "
+ "retry; ask the user)."
+ ),
+ )
+
+
+UPDATABLE_DATASET_FIELDS: frozenset[str] = frozenset(
+ {
+ "table_name",
+ "sql",
+ "description",
+ "main_dttm_col",
+ "cache_timeout",
+ }
+)
+
+
+class UpdateDatasetRequest(BaseModel):
+ """Request schema for update_dataset."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ dataset_id: int | str = Field(
+ ...,
+ description="Dataset identifier — numeric ID or UUID string. "
+ "Use list_datasets to find valid IDs.",
+ )
+ table_name: str | None = Field(
+ None,
+ max_length=250,
+ description="New dataset name. For a virtual dataset this is just its "
+ "label; for a physical dataset it must match an existing table.",
+ )
+ sql: str | None = Field(
+ None,
+ description="New SQL for a virtual dataset. Rejected for physical "
+ "datasets. Columns are re-synced from the new query unless "
+ "sync_columns is false.",
+ )
+ description: str | None = Field(None, description="Dataset description.")
+ main_dttm_col: str | None = Field(
+ None,
+ description="Default datetime column; must be one of the dataset's "
+ "columns (after re-sync, when columns are re-synced).",
+ )
+ cache_timeout: int | None = Field(
+ None,
+ ge=-1,
+ description="Cache timeout in seconds. 0 means the cache never expires, "
+ "-1 bypasses the cache, null falls back to the database default.",
+ )
Review Comment:
✅ **CodeAnt verified this suggestion was addressed in subsequent commits and marked this thread resolved** as of `47ee13d`.
Added a pre-validation validator for `cache_timeout` that rejects boolean values before Pydantic can coerce them to integers.
<sub>If that's not right, unresolve this thread and CodeAnt will leave it open.</sub>
<!-- codeant-auto-resolve-reply -->
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "tien238lnd (via GitHub)" <gi...@apache.org>.
tien238lnd commented on PR #44337:
URL: https://github.com/apache/superset/pull/44337#issuecomment-5793091160
Heads-up on something this tool inherits rather than introduces: #44573.
`update_dataset` reads `model_fields_set` the same way `update_dataset_metric`, `update_chart` and `update_dashboard` do, and its optional fields advertise `"default": null` for the same reason. Against a live instance, a client that fills those defaults turned an update of `table_name` and `sql` into `updated_properties: ["cache_timeout", "description", "main_dttm_col", "sql", "table_name"]`, with `main_dttm_col` cleared, and made `sql` and `table_name` effectively required on every call.
#44573 fixes the three tools already on master, with the shared base and the registration test that keeps it from coming back. I left `update_dataset` out of it to keep the two reviews separate; once #44573 lands I will rebase this branch and put `UpdateDatasetRequest` on the same base, or fold it in here if you prefer.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "tien238lnd (via GitHub)" <gi...@apache.org>.
tien238lnd commented on code in PR #44337:
URL: https://github.com/apache/superset/pull/44337#discussion_r4101776869
##########
superset/mcp_service/dataset/schemas.py:
##########
@@ -718,6 +739,237 @@ class UpdateDatasetMetricResponse(BaseModel):
)
+class DeleteDatasetRequest(BaseModel):
+ """Request schema for delete_dataset."""
+
+ identifier: int | str = Field(
+ ...,
+ description=(
+ "Dataset identifier - numeric ID or UUID string (NOT the table name)."
+ ),
+ )
+
+ @field_validator("identifier", mode="before")
+ @classmethod
+ def reject_bool_identifier(cls, value: object) -> object:
+ """bool is a subclass of int, so identifier=true would coerce to
+ dataset ID 1 and delete the wrong object; reject it outright."""
+ if isinstance(value, bool):
+ raise ValueError("identifier must be an integer ID or UUID string")
+ return value
+
+
+class DeleteDatasetResponse(BaseModel):
+ """Result of a delete_dataset operation."""
+
+ success: bool = Field(description="Whether the dataset was deleted")
+ deleted_id: int | None = Field(None, description="ID of the deleted dataset")
+ deleted_name: str | None = Field(
+ None, description="Table name of the deleted dataset"
+ )
+ soft_deleted: bool = Field(
+ False,
+ description=(
+ "True when the dataset was soft-deleted (moved to trash, because the "
+ "SOFT_DELETE feature flag is enabled) and can be restored by an "
+ "owner or Admin. False means the delete was permanent."
+ ),
+ )
+ affected_chart_count: int = Field(
+ 0,
+ description=(
+ "Number of charts (visible to the caller) built on this dataset. "
+ "They stop working while the dataset is deleted."
+ ),
+ )
+ affected_dashboard_count: int = Field(
+ 0,
+ description=(
+ "Number of dashboards (visible to the caller) containing those charts."
+ ),
+ )
+ message: str | None = Field(None, description="Human-readable outcome message")
+ error: str | None = Field(None, description="Error message if the delete failed")
+ error_type: str | None = Field(None, description="Type of error if failed")
+ permission_denied: bool = Field(
+ False,
+ description=(
+ "True when the caller lacks permission to delete the dataset (do not "
+ "retry; ask the user)."
+ ),
+ )
+
+
+class RestoreDatasetRequest(BaseModel):
+ """Request schema for restore_dataset."""
+
+ identifier: int | str = Field(
+ ...,
+ description=(
+ "Dataset identifier - numeric ID or UUID string (NOT the table name)."
+ ),
+ )
+
+ @field_validator("identifier", mode="before")
+ @classmethod
+ def reject_bool_identifier(cls, value: object) -> object:
+ """bool is a subclass of int, so identifier=true would coerce to
+ dataset ID 1 and target the wrong object; reject it outright."""
+ if isinstance(value, bool):
+ raise ValueError("identifier must be an integer ID or UUID string")
+ return value
+
+
+class RestoreDatasetResponse(BaseModel):
+ """Result of a restore_dataset operation."""
+
+ success: bool = Field(description="Whether the dataset was restored from trash")
+ restored_id: int | None = Field(None, description="ID of the restored dataset")
+ restored_name: str | None = Field(
+ None, description="Table name of the restored dataset"
+ )
+ message: str | None = Field(None, description="Human-readable outcome message")
+ error: str | None = Field(None, description="Error message if the restore failed")
+ error_type: str | None = Field(None, description="Type of error if failed")
+ permission_denied: bool = Field(
+ False,
+ description=(
+ "True when the caller lacks permission to restore the dataset (do not "
+ "retry; ask the user)."
+ ),
+ )
+
+
+UPDATABLE_DATASET_FIELDS: frozenset[str] = frozenset(
+ {
+ "table_name",
+ "sql",
+ "description",
+ "main_dttm_col",
+ "cache_timeout",
+ }
+)
+
+
+class UpdateDatasetRequest(BaseModel):
+ """Request schema for update_dataset."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ dataset_id: int | str = Field(
+ ...,
+ description="Dataset identifier — numeric ID or UUID string. "
+ "Use list_datasets to find valid IDs.",
+ )
+ table_name: str | None = Field(
+ None,
+ max_length=250,
+ description="New dataset name. For a virtual dataset this is just its "
+ "label; for a physical dataset it must match an existing table.",
+ )
+ sql: str | None = Field(
+ None,
+ description="New SQL for a virtual dataset. Rejected for physical "
+ "datasets. Columns are re-synced from the new query unless "
+ "sync_columns is false.",
+ )
+ description: str | None = Field(None, description="Dataset description.")
+ main_dttm_col: str | None = Field(
+ None,
+ description="Default datetime column; must be one of the dataset's "
+ "columns (after re-sync, when columns are re-synced).",
+ )
+ cache_timeout: int | None = Field(
+ None,
+ ge=-1,
+ description="Cache timeout in seconds. 0 means the cache never expires, "
+ "-1 bypasses the cache, null falls back to the database default.",
+ )
Review Comment:
Fixed in 9ded2f2adc: a before-validator rejects `bool` for `cache_timeout` instead of letting it coerce to 1 second.
_🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
##########
superset/mcp_service/dataset/tool/restore_dataset.py:
##########
@@ -0,0 +1,196 @@
+# 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.
+
+"""
+MCP tool: restore_dataset
+"""
+
+import logging
+from typing import Any
+
+from fastmcp import Context
+from sqlalchemy.exc import SQLAlchemyError
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.commands.dataset.exceptions import (
+ DatasetForbiddenError,
+ DatasetLogicalDuplicateError,
+ DatasetNotFoundError,
+)
+from superset.commands.exceptions import CommandException
+from superset.extensions import event_logger
+from superset.mcp_service.dataset.schemas import (
+ RestoreDatasetRequest,
+ RestoreDatasetResponse,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _find_dataset_for_restore(identifier: int | str) -> Any | None:
+ """Resolve a dataset by numeric ID or UUID, including soft-deleted rows.
+
+ Both bypasses mirror ``BaseRestoreCommand.validate``'s own lookup:
+ ``skip_visibility_filter`` unhides the soft-deleted row, and
+ ``skip_base_filter`` keeps an editor's own trash reachable even when the
+ dataset's datasource-access base_filter would hide it (a lost grant must
+ not hide a row from the one audience that can restore it). The restore
+ audience is enforced by ``RestoreDatasetCommand`` via
+ ``raise_for_editorship``.
+ """
+ from superset.daos.dataset import DatasetDAO
+
+ return DatasetDAO.find_by_id_or_uuid(
+ str(identifier),
+ skip_base_filter=True,
+ skip_visibility_filter=True,
+ )
+
+
+def _rollback() -> None:
+ from superset import db
+
+ try:
+ db.session.rollback() # pylint: disable=consider-using-transaction
+ except SQLAlchemyError:
+ logger.warning("Database rollback failed during restore_dataset error handling")
+
+
+@tool(
+ tags=["mutate"],
+ class_permission_name="Dataset",
+ annotations=ToolAnnotations(
+ title="Restore dataset",
+ readOnlyHint=False,
+ destructiveHint=False,
+ idempotentHint=False,
+ openWorldHint=False,
+ ),
+)
+async def restore_dataset(
+ request: RestoreDatasetRequest, ctx: Context
+) -> RestoreDatasetResponse:
+ """Restore a soft-deleted dataset from trash.
+
+ Identify the dataset by numeric ID or UUID string (NOT table name). Only
+ datasets that were soft-deleted (moved to trash while the ``SOFT_DELETE``
+ feature flag was enabled) can be restored; permanently deleted datasets
+ are unrecoverable. The caller must be an editor of the dataset (owners
+ and Admins qualify). Use list_datasets with deleted_state='only' to find
+ trashed datasets.
+
+ Restoring fails with ``error_type`` ``LogicalDuplicate`` when another
+ active dataset already points at the same physical table; that dataset
+ must be deleted or renamed first.
+
+ Example:
+ ```json
+ {"identifier": 123}
+ ```
+
+ Returns success with the restored dataset's id/name, or an error. When the
+ caller lacks permission, ``permission_denied`` is true — do not retry; ask
+ the user.
+ """
+ await ctx.info("Restoring dataset: identifier=%s" % (request.identifier,))
+
+ try:
+ dataset = _find_dataset_for_restore(request.identifier)
+ except SQLAlchemyError:
+ _rollback()
+ logger.exception("Dataset lookup failed during restore_dataset")
+ return RestoreDatasetResponse(
+ success=False,
+ error="Dataset lookup failed due to a database error.",
+ error_type="LookupFailed",
+ )
+ if not dataset:
+ display_id = str(request.identifier)[:200]
+ msg = f"No dataset found with identifier: {display_id}."
+ return RestoreDatasetResponse(success=False, error=msg, error_type="NotFound")
+
+ dataset_id = dataset.id
+ # Table names are user-controlled and must remain exact in response text.
+ dataset_name = dataset.table_name
+
+ if dataset.deleted_at is None:
+ return RestoreDatasetResponse(
+ success=False,
+ error=(
+ f"Dataset '{dataset_name}' (id={dataset_id}) is not in trash; "
+ "nothing to restore."
+ ),
+ error_type="NotDeleted",
+ )
+
+ # The try/except sits inside log_context so failed restore attempts are
+ # recorded in the audit log too — the context manager does not log when
+ # an exception propagates through it.
+ with event_logger.log_context(action="mcp.restore_dataset"):
+ try:
+ from superset.commands.dataset.restore import RestoreDatasetCommand
+
+ RestoreDatasetCommand(str(dataset.uuid)).run()
+
+ return RestoreDatasetResponse(
+ success=True,
+ restored_id=dataset_id,
+ restored_name=dataset_name,
+ message=(
+ f"Restored dataset '{dataset_name}' (id={dataset_id}) from trash."
+ ),
+ )
+ except DatasetForbiddenError:
+ await ctx.warning(
+ "Permission denied restoring dataset id=%s" % (dataset_id,)
+ )
+ return RestoreDatasetResponse(
+ success=False,
+ permission_denied=True,
+ error=(
+ "You do not have permission to restore dataset "
+ f"'{dataset_name}' (id={dataset_id}). Ask the user to restore "
+ "it or grant access; do not retry."
+ ),
+ error_type="Forbidden",
+ )
+ except DatasetLogicalDuplicateError as ex:
Review Comment:
Fixed in a7739e4aa9: `restore_dataset` checks editorship before any response that names the dataset, so a caller without edit rights gets the same shape as a miss.
_🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
##########
superset/mcp_service/dataset/tool/restore_dataset.py:
##########
@@ -0,0 +1,196 @@
+# 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.
+
+"""
+MCP tool: restore_dataset
+"""
+
+import logging
+from typing import Any
+
+from fastmcp import Context
+from sqlalchemy.exc import SQLAlchemyError
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.commands.dataset.exceptions import (
+ DatasetForbiddenError,
+ DatasetLogicalDuplicateError,
+ DatasetNotFoundError,
+)
+from superset.commands.exceptions import CommandException
+from superset.extensions import event_logger
+from superset.mcp_service.dataset.schemas import (
+ RestoreDatasetRequest,
+ RestoreDatasetResponse,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _find_dataset_for_restore(identifier: int | str) -> Any | None:
+ """Resolve a dataset by numeric ID or UUID, including soft-deleted rows.
+
+ Both bypasses mirror ``BaseRestoreCommand.validate``'s own lookup:
+ ``skip_visibility_filter`` unhides the soft-deleted row, and
+ ``skip_base_filter`` keeps an editor's own trash reachable even when the
+ dataset's datasource-access base_filter would hide it (a lost grant must
+ not hide a row from the one audience that can restore it). The restore
+ audience is enforced by ``RestoreDatasetCommand`` via
+ ``raise_for_editorship``.
+ """
+ from superset.daos.dataset import DatasetDAO
+
+ return DatasetDAO.find_by_id_or_uuid(
+ str(identifier),
+ skip_base_filter=True,
+ skip_visibility_filter=True,
+ )
+
+
+def _rollback() -> None:
+ from superset import db
+
+ try:
+ db.session.rollback() # pylint: disable=consider-using-transaction
+ except SQLAlchemyError:
+ logger.warning("Database rollback failed during restore_dataset error handling")
+
+
+@tool(
+ tags=["mutate"],
+ class_permission_name="Dataset",
+ annotations=ToolAnnotations(
+ title="Restore dataset",
+ readOnlyHint=False,
+ destructiveHint=False,
+ idempotentHint=False,
+ openWorldHint=False,
+ ),
+)
+async def restore_dataset(
+ request: RestoreDatasetRequest, ctx: Context
+) -> RestoreDatasetResponse:
+ """Restore a soft-deleted dataset from trash.
+
+ Identify the dataset by numeric ID or UUID string (NOT table name). Only
+ datasets that were soft-deleted (moved to trash while the ``SOFT_DELETE``
+ feature flag was enabled) can be restored; permanently deleted datasets
+ are unrecoverable. The caller must be an editor of the dataset (owners
+ and Admins qualify). Use list_datasets with deleted_state='only' to find
+ trashed datasets.
+
+ Restoring fails with ``error_type`` ``LogicalDuplicate`` when another
+ active dataset already points at the same physical table; that dataset
+ must be deleted or renamed first.
+
+ Example:
+ ```json
+ {"identifier": 123}
+ ```
+
+ Returns success with the restored dataset's id/name, or an error. When the
+ caller lacks permission, ``permission_denied`` is true — do not retry; ask
+ the user.
+ """
+ await ctx.info("Restoring dataset: identifier=%s" % (request.identifier,))
+
+ try:
+ dataset = _find_dataset_for_restore(request.identifier)
+ except SQLAlchemyError:
+ _rollback()
+ logger.exception("Dataset lookup failed during restore_dataset")
+ return RestoreDatasetResponse(
+ success=False,
+ error="Dataset lookup failed due to a database error.",
+ error_type="LookupFailed",
+ )
+ if not dataset:
+ display_id = str(request.identifier)[:200]
+ msg = f"No dataset found with identifier: {display_id}."
+ return RestoreDatasetResponse(success=False, error=msg, error_type="NotFound")
+
+ dataset_id = dataset.id
+ # Table names are user-controlled and must remain exact in response text.
+ dataset_name = dataset.table_name
+
+ if dataset.deleted_at is None:
Review Comment:
Fixed in a7739e4aa9, mirroring `restore_chart`: `_deny_restore` runs the editorship check first, and the NotDeleted branch is reached only by a caller who may edit the dataset.
_🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
##########
superset/mcp_service/dataset/tool/update_dataset.py:
##########
@@ -0,0 +1,289 @@
+# 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.
+
+"""
+MCP tool: update_dataset
+"""
+
+import logging
+from typing import Any
+
+from fastmcp import Context
+from sqlalchemy.exc import SQLAlchemyError
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset import security_manager
+from superset.exceptions import SupersetException
+from superset.extensions import event_logger
+from superset.mcp_service.dataset.schemas import (
+ UpdateDatasetRequest,
+ UpdateDatasetResponse,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _column_names(dataset: Any) -> set[str]:
+ return {column.column_name for column in dataset.columns}
+
+
+def _sync_error_message(ex: Exception) -> str:
+ # Raw SQLAlchemy text can leak SQL or connection details; Superset
+ # exception messages are user-facing by design.
+ if isinstance(ex, SQLAlchemyError):
+ return "a database error occurred"
+ return str(ex)
+
+
+@tool(
+ tags=["mutate"],
+ class_permission_name="Dataset",
+ method_permission_name="write",
+ annotations=ToolAnnotations(
+ title="Update dataset",
+ readOnlyHint=False,
+ # Rewriting a virtual dataset's SQL or re-syncing its columns changes
+ # what every chart built on it queries — non-additive, like
+ # update_chart.
+ destructiveHint=True,
+ idempotentHint=False,
+ openWorldHint=False,
+ ),
+)
+async def update_dataset( # noqa: C901
+ request: UpdateDatasetRequest, ctx: Context
+) -> UpdateDatasetResponse:
+ """Update a dataset's name, SQL, description, default datetime column or
+ cache timeout, and optionally re-sync its columns from the data source.
+
+ Only the properties you pass are changed. ``sql`` applies to virtual
+ datasets only. When ``sql`` changes, columns are re-synced from the new
+ query (like "Sync columns from source" in the dataset editor) unless
+ ``sync_columns`` is false; pass ``sync_columns=true`` on its own to pick
+ up schema changes in the underlying table or query. Calculated columns
+ and saved metrics are kept. Use update_dataset_metric to edit metrics.
+ Requires ownership of the dataset (or Admin).
+
+ Check ``removed_columns`` in the response: charts that use those columns
+ fail until they are updated. ``warnings`` reports problems that did not
+ undo the update, e.g. a column re-sync that failed after the SQL was saved.
+
+ Workflow:
+ 1. Call get_dataset_info to inspect the dataset
+ 2. Call this tool with the dataset ID and only the properties to change
+
+ Example usage:
+ ```json
+ {
+ "dataset_id": 123,
+ "sql": "SELECT region, SUM(revenue) AS revenue FROM sales GROUP BY region",
+ "description": "Revenue by region"
+ }
+ ```
+ """
+ updates = request.updates()
+ await ctx.info(
+ "Updating dataset: dataset_id=%s, properties=%s, sync_columns=%s"
+ % (request.dataset_id, sorted(updates), request.sync_columns)
+ )
+
+ try:
+ from sqlalchemy.orm import joinedload, subqueryload
+
+ from superset.commands.dataset.exceptions import (
+ DatasetForbiddenError,
+ DatasetInvalidError,
+ DatasetNotFoundError,
+ DatasetUpdateFailedError,
+ )
+ from superset.commands.dataset.refresh import RefreshDatasetCommand
+ from superset.commands.dataset.update import UpdateDatasetCommand
+ from superset.connectors.sqla.models import SqlaTable
+ from superset.exceptions import SupersetSecurityException
+ from superset.mcp_service.dataset.dataset_utils import resolve_dataset
+ from superset.mcp_service.utils.url_utils import get_superset_base_url
+
+ eager_options = [
+ subqueryload(SqlaTable.columns),
+ joinedload(SqlaTable.database),
+ ]
+
+ with event_logger.log_context(action="mcp.update_dataset.lookup"):
+ dataset = resolve_dataset(request.dataset_id, eager_options)
+
+ if dataset is None:
+ display_id = str(request.dataset_id)[:200]
+ await ctx.warning("Dataset not found: %s" % (display_id,))
+ return UpdateDatasetResponse(
+ error=(
+ f"No dataset found with identifier: {display_id}."
+ " Use list_datasets to get valid dataset IDs."
+ ),
+ )
+
+ dataset_id = dataset.id
+
+ # Enforce editorship before validating against the dataset's columns,
+ # so a caller without edit rights learns nothing beyond "forbidden".
+ # UpdateDatasetCommand and RefreshDatasetCommand re-check this.
+ try:
+ security_manager.raise_for_editorship(dataset)
+ except SupersetSecurityException:
+ await ctx.warning("Dataset update forbidden: dataset_id=%s" % (dataset_id,))
+ return UpdateDatasetResponse(
+ dataset_id=dataset_id,
+ permission_denied=True,
+ error="You must be an owner of this dataset (or an Admin) to "
+ "update it. Ask the user to update it or grant access; do not "
+ "retry.",
+ )
+
+ if "sql" in updates and not dataset.sql:
+ return UpdateDatasetResponse(
+ dataset_id=dataset_id,
+ error="sql can only be set on a virtual dataset; this dataset "
+ "is a physical table.",
+ )
+
+ sync_columns = (
+ request.sync_columns
+ if request.sync_columns is not None
+ else "sql" in updates and updates["sql"] != dataset.sql
+ )
+
+ # A new default datetime column is checked against the columns the
+ # dataset will have once the update is done: the current ones, or the
+ # re-synced ones, in which case it is applied after the sync.
+ pending_dttm_col = None
+ if updates.get("main_dttm_col") is not None:
+ if sync_columns:
+ pending_dttm_col = updates.pop("main_dttm_col")
+ elif updates["main_dttm_col"] not in _column_names(dataset):
+ dttm_col = updates["main_dttm_col"]
+ return UpdateDatasetResponse(
+ dataset_id=dataset_id,
+ error=f"main_dttm_col '{dttm_col}' is not a column of this "
+ "dataset. Use get_dataset_info to list its columns.",
+ )
+
+ columns_before = _column_names(dataset)
+ updated_properties = sorted(updates)
+
+ if updates:
+ # Same pair of commands as PUT /api/v1/dataset/<pk>?override_columns=
+ # — the update commits before the column refresh runs.
+ with event_logger.log_context(action="mcp.update_dataset.update"):
+ dataset = UpdateDatasetCommand(
+ dataset_id, updates, override_columns=sync_columns
+ ).run()
+
+ warnings: list[str] = []
+ added_columns: list[str] = []
+ removed_columns: list[str] = []
+ columns_synced = False
+ if sync_columns:
+ try:
+ with event_logger.log_context(action="mcp.update_dataset.sync_columns"):
+ dataset = RefreshDatasetCommand(dataset_id).run()
+ columns_after = _column_names(dataset)
+ added_columns = sorted(columns_after - columns_before)
+ removed_columns = sorted(columns_before - columns_after)
+ columns_synced = True
+ except (SupersetException, SQLAlchemyError) as ex:
+ await ctx.warning(
+ "Dataset column sync failed: %s: %s" % (type(ex).__name__, ex)
+ )
+ warnings.append(
+ "The update was saved, but re-syncing columns failed "
+ f"({_sync_error_message(ex)}). The column list may not "
+ "match the dataset's SQL; retry with sync_columns=true."
+ )
+
+ if pending_dttm_col is not None:
+ if not columns_synced:
+ warnings.append(
+ f"main_dttm_col was not changed to '{pending_dttm_col}' "
+ "because the columns could not be re-synced."
+ )
+ elif pending_dttm_col not in _column_names(dataset):
+ warnings.append(
+ f"main_dttm_col was not changed: '{pending_dttm_col}' is not "
+ "a column of the dataset after the update."
+ )
+ else:
+ with event_logger.log_context(action="mcp.update_dataset.update"):
+ dataset = UpdateDatasetCommand(
+ dataset_id, {"main_dttm_col": pending_dttm_col}
+ ).run()
Review Comment:
Fixed in a7739e4aa9: the second `UpdateDatasetCommand` is wrapped, and a failure after the first commit is reported as a partial success — the field stays out of `updated_properties` and a warning says what was and was not saved.
_🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
##########
tests/unit_tests/mcp_service/dataset/tool/test_dataset_tools.py:
##########
@@ -97,6 +97,7 @@ def create_mock_dataset(
dataset.template_params = {}
dataset.extra = {}
dataset.uuid = f"test-dataset-uuid-{dataset_id}"
+ dataset.deleted_at = None
Review Comment:
Fixed in 9ded2f2adc: all 14 bare `MagicMock()` datasets in the file set `deleted_at = None`, not just the shared fixture.
_🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
##########
tests/unit_tests/mcp_service/dataset/tool/test_dataset_tools.py:
##########
@@ -98,6 +98,7 @@ def create_mock_dataset(
dataset.template_params = {}
dataset.extra = {}
dataset.uuid = f"test-dataset-uuid-{dataset_id}"
+ dataset.deleted_at = None
Review Comment:
Fixed in 9ded2f2adc — thank you for measuring it; the tests passed while every dataset read as trashed at 1970-01-01.
_🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #44337:
URL: https://github.com/apache/superset/pull/44337#issuecomment-5808412241
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
<details>
<summary>
Bito Usage Guide
</summary>
**Commands**
Type the following command in the pull request comment and save the comment.
- `/review` - Manually triggers an incremental AI Review.
- `/review full` - Manually triggers a full AI Review.
- `/pause` - Pauses automatic reviews on this pull request.
- `/resume` - Resumes automatic reviews.
- `/resolve` - Marks all Bito-posted review comments as resolved.
- `/abort` - Cancels all in-progress reviews.
Refer to the <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/available-commands" target="_blank" rel="noopener noreferrer">documentation</a> for additional commands.
**Configuration**
This repository uses `Superset` You can customize the agent settings <a href="https://alpha.bito.ai/home/ai-agents/code-review-agent" target="_blank" rel="noopener noreferrer">here</a> or contact your Bito workspace admin at evan@preset.io.
**Documentation & Help**
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/getting-started/install-run-using-bito-cloud/create-or-customize-an-agent-instance" target="_blank" rel="noopener noreferrer">Customize agent settings</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/implementing-custom-code-review-rules" target="_blank" rel="noopener noreferrer">Review rules</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent" target="_blank" rel="noopener noreferrer">General documentation</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/faqs" target="_blank" rel="noopener noreferrer">FAQ</a>
</details>
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "netlify[bot] (via GitHub)" <gi...@apache.org>.
netlify[bot] commented on PR #44337:
URL: https://github.com/apache/superset/pull/44337#issuecomment-5756793556
### <span aria-hidden="true">✅</span> Deploy Preview for *superset-docs-preview* ready!
| Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 9ded2f2adc4338c76eb3ee9fc61daaaf02048cb6 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/projects/superset-docs-preview/deploys/6ab0d7b09430c400089a8679 |
|<span aria-hidden="true">😎</span> Deploy Preview | [https://deploy-preview-44337--superset-docs-preview.netlify.app](https://deploy-preview-44337--superset-docs-preview.netlify.app) |
|<span aria-hidden="true">📱</span> Preview on mobile | <details><summary> Toggle QR Code... </summary><br /><br /><br /><br />_Use your smartphone camera to open QR code link._</details> |
|<span aria-hidden="true">🤖</span> Make changes | [Run an agent on this branch](https://app.netlify.com/projects/superset-docs-preview/agent-runs#DcpBCoAgEADAv-w5aSFJ7Qc9Y12NAs3FvEj097zNYV6QWrI02AAm8JVuPoelzplFBWr0xKbSdUTunOI4IUoqfQ9jreQxGI9OL8gaEa0juxoH3w8) |
---
<!-- [superset-docs-preview Preview](https://deploy-preview-44337--superset-docs-preview.netlify.app) -->
_To edit notification comments on pull requests, go to your [Netlify project configuration](https://app.netlify.com/projects/superset-docs-preview/configuration/notifications#deploy-notifications)._
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on code in PR #44337:
URL: https://github.com/apache/superset/pull/44337#discussion_r4089248220
##########
tests/unit_tests/mcp_service/dataset/tool/test_list_datasets_deleted_state.py:
##########
@@ -0,0 +1,205 @@
+# 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_datasets ``deleted_state`` trash listing.
+
+The deleted_state param opts the list query into surfacing soft-deleted
+datasets: the session-scoped visibility bypass wraps the DAO call, the REST
+``DatasetDeletedStateFilter`` (which owns the restore-audience scoping) is
+passed through as a DAO custom filter, and ``deleted_at`` is forced into the
+loaded columns so trashed rows are distinguishable in the response.
+"""
+
+import importlib
+from collections.abc import Iterator
+from datetime import datetime
+from unittest.mock import MagicMock, Mock, patch
+from uuid import UUID
+
+import pytest
+from fastmcp import Client
+from fastmcp.exceptions import ToolError
+
+from superset.mcp_service.app import mcp
+from superset.utils import json
+
+_DAO_LIST = "superset.daos.dataset.DatasetDAO.list"
+_BYPASS = "superset.mcp_service.mcp_core.skip_visibility_filter"
+
+list_datasets_module = importlib.import_module(
+ "superset.mcp_service.dataset.tool.list_datasets"
+)
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ 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() -> Iterator[None]:
+ """Keep the tests on the metadata-allowed path; privacy gating is covered
+ in test_dataset_tools."""
+ with patch.object(
+ list_datasets_module, "user_can_view_data_model_metadata", return_value=True
+ ):
+ yield
+
+
+def _trashed_dataset_row() -> Mock:
+ row = Mock(
+ spec=[
+ "id",
+ "table_name",
+ "schema",
+ "changed_on",
+ "created_on",
+ "uuid",
+ "deleted_at",
+ ]
+ )
+ row.id = 10
+ row.table_name = "trashed_orders"
+ row.schema = "public"
+ row.changed_on = datetime(2026, 6, 1)
+ row.created_on = datetime(2026, 5, 1)
+ row.uuid = UUID("11111111-2222-3333-4444-555555555555")
+ row.deleted_at = datetime(2026, 7, 1)
+ return row
+
+
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_datasets_deleted_state_only_passes_custom_filter(
+ mock_list: Mock, mcp_server: object
+) -> None:
+ mock_list.return_value = ([], 0)
+
+ async with Client(mcp_server) as client:
+ await client.call_tool("list_datasets", {"request": {"deleted_state": "only"}})
+
+ kwargs = mock_list.call_args.kwargs
+ assert "deleted_state" in (kwargs.get("custom_filters") or {})
+ assert "deleted_at" in kwargs["columns"]
+
+
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_datasets_deleted_state_combines_with_certified(
+ mock_list: Mock, mcp_server: object
+) -> None:
+ mock_list.return_value = ([], 0)
+
+ async with Client(mcp_server) as client:
+ await client.call_tool(
+ "list_datasets",
+ {"request": {"deleted_state": "include", "certified": True}},
+ )
+
+ custom_filters = mock_list.call_args.kwargs.get("custom_filters") or {}
+ assert {"deleted_state", "certified"} <= set(custom_filters)
+
+
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_datasets_default_has_no_deleted_state_filter(
+ mock_list: Mock, mcp_server: object
+) -> None:
+ mock_list.return_value = ([], 0)
+
+ async with Client(mcp_server) as client:
+ await client.call_tool("list_datasets", {})
+
+ kwargs = mock_list.call_args.kwargs
+ assert "deleted_state" not in (kwargs.get("custom_filters") or {})
+
+
+@patch(_BYPASS)
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_datasets_deleted_state_wraps_visibility_bypass(
+ mock_list: Mock, mock_bypass: MagicMock, mcp_server: object
+) -> None:
+ from superset.connectors.sqla.models import SqlaTable
+
+ mock_list.return_value = ([], 0)
+ mock_bypass.return_value.__enter__ = Mock(return_value=None)
+ mock_bypass.return_value.__exit__ = Mock(return_value=False)
+
+ async with Client(mcp_server) as client:
+ await client.call_tool(
+ "list_datasets", {"request": {"deleted_state": "include"}}
+ )
+
+ mock_bypass.assert_called_once()
+ assert SqlaTable in mock_bypass.call_args.args
+
+
+@patch(_BYPASS)
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_datasets_no_deleted_state_no_visibility_bypass(
+ mock_list: Mock, mock_bypass: MagicMock, mcp_server: object
+) -> None:
+ mock_list.return_value = ([], 0)
+
+ async with Client(mcp_server) as client:
+ await client.call_tool("list_datasets", {})
+
+ mock_bypass.assert_not_called()
+
+
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_datasets_deleted_state_serializes_deleted_at(
+ mock_list: Mock, mcp_server: object
+) -> None:
+ mock_list.return_value = ([_trashed_dataset_row()], 1)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "list_datasets", {"request": {"deleted_state": "only"}}
+ )
+
+ data = json.loads(result.content[0].text)
+ assert data["datasets"][0]["id"] == 10
+ assert data["datasets"][0]["deleted_at"] is not None
+ assert "deleted_at" in data["columns_loaded"]
+
+
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_datasets_deleted_state_invalid_value_rejected(
+ mock_list: Mock, mcp_server: object
+) -> None:
Review Comment:
<!-- Bito Reply -->
The suggestion to add a docstring to the new test function is appropriate. Adding a docstring ensures consistency with the existing test suite and adheres to the project's documentation standards for new test functions.
**tests/unit_tests/mcp_service/dataset/tool/test_list_datasets_deleted_state.py**
```
@patch(_DAO_LIST)
@pytest.mark.asyncio
async def test_list_datasets_deleted_state_invalid_value_rejected(
mock_list: Mock, mcp_server: object
) -> None:
"""Test that an invalid deleted_state value is rejected."""
# Implementation...
```
##########
tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py:
##########
@@ -0,0 +1,344 @@
+# 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 the delete_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_RESOLVE = "superset.mcp_service.dataset.tool.delete_dataset.resolve_dataset"
+_COUNT = "superset.mcp_service.dataset.tool.delete_dataset._count_affected_objects"
+_RUN = "superset.commands.dataset.delete.DeleteDatasetCommand.run"
+_VALIDATE = "superset.commands.dataset.delete.DeleteDatasetCommand.validate"
+_FLAG = "superset.mcp_service.dataset.tool.delete_dataset.is_feature_enabled"
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ """Provide the FastMCP app instance under test."""
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ """Authenticate every tool call as a mock admin user."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool validates the command (lookup + editorship) before counting
+ # dependents; default it to a pass so tests that patch run() alone keep
+ # working. The permission test re-patches it to raise.
+ with patch(_VALIDATE):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(dataset_id: int = 10, table_name: str = "orders") -> Mock:
+ """Build a minimal dataset stand-in with the attributes the tool reads."""
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ return dataset
+
+
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_not_found(mock_resolve: Mock, mcp_server: object) -> None:
+ """An identifier that resolves to no dataset returns NotFound naming it."""
+ mock_resolve.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_success(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ """The happy path: the dataset is resolved, deleted and its dependents counted."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["deleted_id"] == 10
+ assert "orders" in content["deleted_name"]
+ assert content["permission_denied"] is False
+ assert content["affected_chart_count"] == 0
+ mock_run.assert_called_once()
+ mock_count.assert_called_once_with(10)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_by_uuid(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ """A UUID identifier is passed through to the dataset lookup unchanged."""
+ uuid = "11111111-2222-3333-4444-555555555555"
+ mock_resolve.return_value = _mock_dataset(dataset_id=10)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": uuid}}
+ )
+
+ assert result.structured_content["success"] is True
+ mock_resolve.assert_called_once_with(uuid)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_FLAG)
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_soft_delete_reports_restorable(
+ mock_resolve: Mock,
+ mock_run: Mock,
+ mock_flag: Mock,
+ mock_count: Mock,
+ mcp_server: object,
+) -> None:
+ """With SOFT_DELETE on, the response says the dataset can be restored."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+ mock_flag.side_effect = lambda flag: flag == "SOFT_DELETE"
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["soft_deleted"] is True
+ assert "restor" in (content["message"] or "").lower()
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_FLAG)
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_hard_delete_reports_permanent(
+ mock_resolve: Mock,
+ mock_run: Mock,
+ mock_flag: Mock,
+ mock_count: Mock,
+ mcp_server: object,
+) -> None:
+ """With SOFT_DELETE off, the response says the delete is permanent."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+ mock_flag.return_value = False
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["soft_deleted"] is False
+ assert "permanent" in (content["message"] or "").lower()
+
+
+@patch(_COUNT, return_value=(3, 2))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_reports_affected_charts(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ """Dependent charts and dashboards are counted in the response."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["affected_chart_count"] == 3
+ assert content["affected_dashboard_count"] == 2
+ assert "3 chart(s)" in (content["message"] or "")
+
+
+def test_count_affected_objects_only_counts_accessible() -> None:
+ """Counts must not disclose charts/dashboards the caller cannot access."""
+ from superset.mcp_service.dataset.tool.delete_dataset import (
+ _count_affected_objects,
+ )
Review Comment:
<!-- Bito Reply -->
The inline import of `_count_affected_objects` in the test file is acceptable in this context. Since the module is already imported at the top level in the production code and the test file, moving this specific helper function to the module level is a reasonable adjustment to align with standard practices, even if a circular dependency is not present.
**tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py**
```
from superset.mcp_service.dataset.tool.delete_dataset import (
_count_affected_objects,
)
```
##########
tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py:
##########
@@ -0,0 +1,344 @@
+# 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 the delete_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_RESOLVE = "superset.mcp_service.dataset.tool.delete_dataset.resolve_dataset"
+_COUNT = "superset.mcp_service.dataset.tool.delete_dataset._count_affected_objects"
+_RUN = "superset.commands.dataset.delete.DeleteDatasetCommand.run"
+_VALIDATE = "superset.commands.dataset.delete.DeleteDatasetCommand.validate"
+_FLAG = "superset.mcp_service.dataset.tool.delete_dataset.is_feature_enabled"
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ """Provide the FastMCP app instance under test."""
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ """Authenticate every tool call as a mock admin user."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool validates the command (lookup + editorship) before counting
+ # dependents; default it to a pass so tests that patch run() alone keep
+ # working. The permission test re-patches it to raise.
+ with patch(_VALIDATE):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(dataset_id: int = 10, table_name: str = "orders") -> Mock:
+ """Build a minimal dataset stand-in with the attributes the tool reads."""
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ return dataset
+
+
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_not_found(mock_resolve: Mock, mcp_server: object) -> None:
+ """An identifier that resolves to no dataset returns NotFound naming it."""
+ mock_resolve.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_success(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ """The happy path: the dataset is resolved, deleted and its dependents counted."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["deleted_id"] == 10
+ assert "orders" in content["deleted_name"]
+ assert content["permission_denied"] is False
+ assert content["affected_chart_count"] == 0
+ mock_run.assert_called_once()
+ mock_count.assert_called_once_with(10)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_by_uuid(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ """A UUID identifier is passed through to the dataset lookup unchanged."""
+ uuid = "11111111-2222-3333-4444-555555555555"
+ mock_resolve.return_value = _mock_dataset(dataset_id=10)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": uuid}}
+ )
+
+ assert result.structured_content["success"] is True
+ mock_resolve.assert_called_once_with(uuid)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_FLAG)
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_soft_delete_reports_restorable(
+ mock_resolve: Mock,
+ mock_run: Mock,
+ mock_flag: Mock,
+ mock_count: Mock,
+ mcp_server: object,
+) -> None:
+ """With SOFT_DELETE on, the response says the dataset can be restored."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+ mock_flag.side_effect = lambda flag: flag == "SOFT_DELETE"
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["soft_deleted"] is True
+ assert "restor" in (content["message"] or "").lower()
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_FLAG)
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_hard_delete_reports_permanent(
+ mock_resolve: Mock,
+ mock_run: Mock,
+ mock_flag: Mock,
+ mock_count: Mock,
+ mcp_server: object,
+) -> None:
+ """With SOFT_DELETE off, the response says the delete is permanent."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+ mock_flag.return_value = False
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["soft_deleted"] is False
+ assert "permanent" in (content["message"] or "").lower()
+
+
+@patch(_COUNT, return_value=(3, 2))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_reports_affected_charts(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ """Dependent charts and dashboards are counted in the response."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["affected_chart_count"] == 3
+ assert content["affected_dashboard_count"] == 2
+ assert "3 chart(s)" in (content["message"] or "")
+
+
+def test_count_affected_objects_only_counts_accessible() -> None:
+ """Counts must not disclose charts/dashboards the caller cannot access."""
+ from superset.mcp_service.dataset.tool.delete_dataset import (
+ _count_affected_objects,
+ )
+
+ visible_chart, hidden_chart = Mock(), Mock()
+ visible_dashboard, hidden_dashboard = Mock(), Mock()
+ security_manager = Mock()
+ security_manager.can_access_chart.side_effect = lambda c: c is visible_chart
+ security_manager.can_access_dashboard.side_effect = lambda d: d is visible_dashboard
Review Comment:
<!-- Bito Reply -->
The decision to retain the lambda functions is appropriate in this context. Using `side_effect` with a lambda provides a concise, one-argument identity check that is directly tied to the specific mock objects defined in the test, whereas extracting named helpers would introduce unnecessary boilerplate for a simple visibility predicate.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on code in PR #44337:
URL: https://github.com/apache/superset/pull/44337#discussion_r4089256280
##########
tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py:
##########
@@ -0,0 +1,344 @@
+# 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 the delete_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_RESOLVE = "superset.mcp_service.dataset.tool.delete_dataset.resolve_dataset"
+_COUNT = "superset.mcp_service.dataset.tool.delete_dataset._count_affected_objects"
+_RUN = "superset.commands.dataset.delete.DeleteDatasetCommand.run"
+_VALIDATE = "superset.commands.dataset.delete.DeleteDatasetCommand.validate"
+_FLAG = "superset.mcp_service.dataset.tool.delete_dataset.is_feature_enabled"
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ """Provide the FastMCP app instance under test."""
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ """Authenticate every tool call as a mock admin user."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool validates the command (lookup + editorship) before counting
+ # dependents; default it to a pass so tests that patch run() alone keep
+ # working. The permission test re-patches it to raise.
+ with patch(_VALIDATE):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(dataset_id: int = 10, table_name: str = "orders") -> Mock:
+ """Build a minimal dataset stand-in with the attributes the tool reads."""
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ return dataset
+
+
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_not_found(mock_resolve: Mock, mcp_server: object) -> None:
+ """An identifier that resolves to no dataset returns NotFound naming it."""
+ mock_resolve.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_success(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ """The happy path: the dataset is resolved, deleted and its dependents counted."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["deleted_id"] == 10
+ assert "orders" in content["deleted_name"]
+ assert content["permission_denied"] is False
+ assert content["affected_chart_count"] == 0
+ mock_run.assert_called_once()
+ mock_count.assert_called_once_with(10)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_by_uuid(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ """A UUID identifier is passed through to the dataset lookup unchanged."""
+ uuid = "11111111-2222-3333-4444-555555555555"
+ mock_resolve.return_value = _mock_dataset(dataset_id=10)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": uuid}}
+ )
+
+ assert result.structured_content["success"] is True
+ mock_resolve.assert_called_once_with(uuid)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_FLAG)
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_soft_delete_reports_restorable(
+ mock_resolve: Mock,
+ mock_run: Mock,
+ mock_flag: Mock,
+ mock_count: Mock,
+ mcp_server: object,
+) -> None:
+ """With SOFT_DELETE on, the response says the dataset can be restored."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+ mock_flag.side_effect = lambda flag: flag == "SOFT_DELETE"
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["soft_deleted"] is True
+ assert "restor" in (content["message"] or "").lower()
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_FLAG)
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_hard_delete_reports_permanent(
+ mock_resolve: Mock,
+ mock_run: Mock,
+ mock_flag: Mock,
+ mock_count: Mock,
+ mcp_server: object,
+) -> None:
+ """With SOFT_DELETE off, the response says the delete is permanent."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+ mock_flag.return_value = False
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["soft_deleted"] is False
+ assert "permanent" in (content["message"] or "").lower()
+
+
+@patch(_COUNT, return_value=(3, 2))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_reports_affected_charts(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ """Dependent charts and dashboards are counted in the response."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["affected_chart_count"] == 3
+ assert content["affected_dashboard_count"] == 2
+ assert "3 chart(s)" in (content["message"] or "")
+
+
+def test_count_affected_objects_only_counts_accessible() -> None:
+ """Counts must not disclose charts/dashboards the caller cannot access."""
+ from superset.mcp_service.dataset.tool.delete_dataset import (
+ _count_affected_objects,
+ )
+
+ visible_chart, hidden_chart = Mock(), Mock()
+ visible_dashboard, hidden_dashboard = Mock(), Mock()
+ security_manager = Mock()
+ security_manager.can_access_chart.side_effect = lambda c: c is visible_chart
+ security_manager.can_access_dashboard.side_effect = lambda d: d is visible_dashboard
+
+ with (
+ patch(
+ "superset.daos.dataset.DatasetDAO.get_related_objects",
+ return_value={
+ "charts": [visible_chart, hidden_chart],
+ "dashboards": [visible_dashboard, hidden_dashboard],
+ },
+ ) as mock_related,
+ patch("superset.security_manager", security_manager),
+ ):
+ assert _count_affected_objects(10) == (1, 1)
+
+ mock_related.assert_called_once_with(10)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_permission_denied(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ """A caller who may not delete the dataset gets Forbidden before any
+ dependents are counted or anything is deleted."""
+ from superset.commands.dataset.exceptions import DatasetForbiddenError
Review Comment:
<!-- Bito Reply -->
The reviewer's suggestion to move the `DatasetForbiddenError` import to the module level is appropriate. Since `delete_dataset.py` already performs this import at the module level, moving it to the top level in the test file is consistent, improves readability, and avoids potential issues with inline imports.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "tien238lnd (via GitHub)" <gi...@apache.org>.
tien238lnd commented on code in PR #44337:
URL: https://github.com/apache/superset/pull/44337#discussion_r4089245172
##########
superset/mcp_service/dataset/schemas.py:
##########
@@ -750,6 +771,246 @@ class UpdateDatasetMetricResponse(BaseModel):
)
+class DeleteDatasetRequest(BaseModel):
+ """Request schema for delete_dataset."""
+
+ identifier: int | str = Field(
+ ...,
+ description=(
+ "Dataset identifier - numeric ID or UUID string (NOT the table name)."
+ ),
+ )
+
+ @field_validator("identifier", mode="before")
+ @classmethod
+ def reject_bool_identifier(cls, value: object) -> object:
+ """bool is a subclass of int, so identifier=true would coerce to
+ dataset ID 1 and delete the wrong object; reject it outright."""
+ if isinstance(value, bool):
+ raise ValueError("identifier must be an integer ID or UUID string")
+ return value
+
+
+class DeleteDatasetResponse(BaseModel):
+ """Result of a delete_dataset operation."""
+
+ success: bool = Field(description="Whether the dataset was deleted")
+ deleted_id: int | None = Field(None, description="ID of the deleted dataset")
+ deleted_name: str | None = Field(
+ None, description="Table name of the deleted dataset"
+ )
+ soft_deleted: bool = Field(
+ False,
+ description=(
+ "True when the dataset was soft-deleted (moved to trash, because the "
+ "SOFT_DELETE feature flag is enabled) and can be restored by an "
+ "owner or Admin. False means the delete was permanent."
+ ),
+ )
+ affected_chart_count: int = Field(
+ 0,
+ description=(
+ "Number of charts (visible to the caller) built on this dataset. "
+ "They stop working while the dataset is deleted."
+ ),
+ )
+ affected_dashboard_count: int = Field(
+ 0,
+ description=(
+ "Number of dashboards (visible to the caller) containing those charts."
+ ),
+ )
+ message: str | None = Field(None, description="Human-readable outcome message")
+ error: str | None = Field(None, description="Error message if the delete failed")
+ error_type: str | None = Field(None, description="Type of error if failed")
+ permission_denied: bool = Field(
+ False,
+ description=(
+ "True when the caller lacks permission to delete the dataset (do not "
+ "retry; ask the user)."
+ ),
+ )
+
+
+class RestoreDatasetRequest(BaseModel):
+ """Request schema for restore_dataset."""
+
+ identifier: int | str = Field(
+ ...,
+ description=(
+ "Dataset identifier - numeric ID or UUID string (NOT the table name)."
+ ),
+ )
+
+ @field_validator("identifier", mode="before")
+ @classmethod
+ def reject_bool_identifier(cls, value: object) -> object:
+ """bool is a subclass of int, so identifier=true would coerce to
+ dataset ID 1 and target the wrong object; reject it outright."""
+ if isinstance(value, bool):
+ raise ValueError("identifier must be an integer ID or UUID string")
+ return value
+
+
+class RestoreDatasetResponse(BaseModel):
+ """Result of a restore_dataset operation."""
+
+ success: bool = Field(description="Whether the dataset was restored from trash")
+ restored_id: int | None = Field(None, description="ID of the restored dataset")
+ restored_name: str | None = Field(
+ None, description="Table name of the restored dataset"
+ )
+ message: str | None = Field(None, description="Human-readable outcome message")
+ error: str | None = Field(None, description="Error message if the restore failed")
+ error_type: str | None = Field(None, description="Type of error if failed")
+ permission_denied: bool = Field(
+ False,
+ description=(
+ "True when the caller lacks permission to restore the dataset (do not "
+ "retry; ask the user)."
+ ),
+ )
+
+
+UPDATABLE_DATASET_FIELDS: frozenset[str] = frozenset(
+ {
+ "table_name",
+ "sql",
+ "description",
+ "main_dttm_col",
+ "cache_timeout",
+ }
+)
+
+
+class UpdateDatasetRequest(BaseModel):
Review Comment:
Fair point, and #44573 now closes most of it. Alongside the hardcoded tool tuple there is a second test, `test_omitted_means_unchanged_models_advertise_no_null_default`, which walks every subclass of `OmittedMeansUnchanged` and checks `model_json_schema()` directly. So whichever merges second only has to put `UpdateDatasetRequest` on the base — one line in the class statement — and the invariant is enforced from then on without touching the test.
The tuple stays hardcoded because it checks a different thing: what the tools advertise over MCP, after FastMCP has built the schema. I would rather keep adding a name there by hand than derive the list and have it quietly cover nothing.
Since `OmittedMeansUnchanged` only exists in #44573, I am not adding the base here while both are open. When one lands I will rebase the other and make the change in that PR.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "codeant-ai-for-open-source[bot] (via GitHub)" <gi...@apache.org>.
codeant-ai-for-open-source[bot] commented on code in PR #44337:
URL: https://github.com/apache/superset/pull/44337#discussion_r4023726798
##########
superset/mcp_service/dataset/tool/restore_dataset.py:
##########
@@ -0,0 +1,196 @@
+# 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.
+
+"""
+MCP tool: restore_dataset
+"""
+
+import logging
+from typing import Any
+
+from fastmcp import Context
+from sqlalchemy.exc import SQLAlchemyError
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.commands.dataset.exceptions import (
+ DatasetForbiddenError,
+ DatasetLogicalDuplicateError,
+ DatasetNotFoundError,
+)
+from superset.commands.exceptions import CommandException
+from superset.extensions import event_logger
+from superset.mcp_service.dataset.schemas import (
+ RestoreDatasetRequest,
+ RestoreDatasetResponse,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _find_dataset_for_restore(identifier: int | str) -> Any | None:
+ """Resolve a dataset by numeric ID or UUID, including soft-deleted rows.
+
+ Both bypasses mirror ``BaseRestoreCommand.validate``'s own lookup:
+ ``skip_visibility_filter`` unhides the soft-deleted row, and
+ ``skip_base_filter`` keeps an editor's own trash reachable even when the
+ dataset's datasource-access base_filter would hide it (a lost grant must
+ not hide a row from the one audience that can restore it). The restore
+ audience is enforced by ``RestoreDatasetCommand`` via
+ ``raise_for_editorship``.
+ """
+ from superset.daos.dataset import DatasetDAO
+
+ return DatasetDAO.find_by_id_or_uuid(
+ str(identifier),
+ skip_base_filter=True,
+ skip_visibility_filter=True,
+ )
+
+
+def _rollback() -> None:
+ from superset import db
+
+ try:
+ db.session.rollback() # pylint: disable=consider-using-transaction
+ except SQLAlchemyError:
+ logger.warning("Database rollback failed during restore_dataset error handling")
+
+
+@tool(
+ tags=["mutate"],
+ class_permission_name="Dataset",
+ annotations=ToolAnnotations(
+ title="Restore dataset",
+ readOnlyHint=False,
+ destructiveHint=False,
+ idempotentHint=False,
+ openWorldHint=False,
+ ),
+)
+async def restore_dataset(
+ request: RestoreDatasetRequest, ctx: Context
+) -> RestoreDatasetResponse:
+ """Restore a soft-deleted dataset from trash.
+
+ Identify the dataset by numeric ID or UUID string (NOT table name). Only
+ datasets that were soft-deleted (moved to trash while the ``SOFT_DELETE``
+ feature flag was enabled) can be restored; permanently deleted datasets
+ are unrecoverable. The caller must be an editor of the dataset (owners
+ and Admins qualify). Use list_datasets with deleted_state='only' to find
+ trashed datasets.
+
+ Restoring fails with ``error_type`` ``LogicalDuplicate`` when another
+ active dataset already points at the same physical table; that dataset
+ must be deleted or renamed first.
+
+ Example:
+ ```json
+ {"identifier": 123}
+ ```
+
+ Returns success with the restored dataset's id/name, or an error. When the
+ caller lacks permission, ``permission_denied`` is true — do not retry; ask
+ the user.
+ """
+ await ctx.info("Restoring dataset: identifier=%s" % (request.identifier,))
+
+ try:
+ dataset = _find_dataset_for_restore(request.identifier)
+ except SQLAlchemyError:
+ _rollback()
+ logger.exception("Dataset lookup failed during restore_dataset")
+ return RestoreDatasetResponse(
+ success=False,
+ error="Dataset lookup failed due to a database error.",
+ error_type="LookupFailed",
+ )
+ if not dataset:
+ display_id = str(request.identifier)[:200]
+ msg = f"No dataset found with identifier: {display_id}."
+ return RestoreDatasetResponse(success=False, error=msg, error_type="NotFound")
+
+ dataset_id = dataset.id
+ # Table names are user-controlled and must remain exact in response text.
+ dataset_name = dataset.table_name
+
+ if dataset.deleted_at is None:
+ return RestoreDatasetResponse(
+ success=False,
+ error=(
+ f"Dataset '{dataset_name}' (id={dataset_id}) is not in trash; "
+ "nothing to restore."
+ ),
+ error_type="NotDeleted",
+ )
+
+ # The try/except sits inside log_context so failed restore attempts are
+ # recorded in the audit log too — the context manager does not log when
+ # an exception propagates through it.
+ with event_logger.log_context(action="mcp.restore_dataset"):
+ try:
+ from superset.commands.dataset.restore import RestoreDatasetCommand
+
+ RestoreDatasetCommand(str(dataset.uuid)).run()
+
+ return RestoreDatasetResponse(
+ success=True,
+ restored_id=dataset_id,
+ restored_name=dataset_name,
+ message=(
+ f"Restored dataset '{dataset_name}' (id={dataset_id}) from trash."
+ ),
+ )
+ except DatasetForbiddenError:
+ await ctx.warning(
+ "Permission denied restoring dataset id=%s" % (dataset_id,)
+ )
+ return RestoreDatasetResponse(
+ success=False,
+ permission_denied=True,
+ error=(
+ "You do not have permission to restore dataset "
+ f"'{dataset_name}' (id={dataset_id}). Ask the user to restore "
+ "it or grant access; do not retry."
+ ),
+ error_type="Forbidden",
+ )
+ except DatasetLogicalDuplicateError as ex:
Review Comment:
**Suggestion:** The lookup bypasses visibility filters, then this forbidden response includes the dataset name and ID, allowing unauthorized callers to confirm hidden datasets and disclose table names. [security]
**Assessment:** 🟠 `Major` · 🔁 `Occurrence: Sometimes`
[](https://docs.codeant.ai/cli/resolve-pr-comments-skill) [](https://app.codeant.ai/fix-in-ide?tool=cursor&prompt_id=daa11480896b413293d390b7d2125ac7&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset) [](https://app.codeant.ai/fix-in-ide?tool=vscode-claude&prompt_id=daa11480896b413293d390b7d2125ac7&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:** superset/mcp_service/dataset/tool/restore_dataset.py
**Line:** 161:171
**Comment:**
*Security: The lookup bypasses visibility filters, then this forbidden response includes the dataset name and ID, allowing unauthorized callers to confirm hidden datasets and disclose table names.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
```
</details>
<a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F44337&comment_hash=4ba715f72aa8c6b65b93057dbb8a9acae21d93dbb1ad80e8ab5c1ccf7678acff&reaction=like'>👍</a> | <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F44337&comment_hash=4ba715f72aa8c6b65b93057dbb8a9acae21d93dbb1ad80e8ab5c1ccf7678acff&reaction=dislike'>👎</a>
##########
superset/mcp_service/dataset/tool/update_dataset.py:
##########
@@ -0,0 +1,289 @@
+# 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.
+
+"""
+MCP tool: update_dataset
+"""
+
+import logging
+from typing import Any
+
+from fastmcp import Context
+from sqlalchemy.exc import SQLAlchemyError
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset import security_manager
+from superset.exceptions import SupersetException
+from superset.extensions import event_logger
+from superset.mcp_service.dataset.schemas import (
+ UpdateDatasetRequest,
+ UpdateDatasetResponse,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _column_names(dataset: Any) -> set[str]:
+ return {column.column_name for column in dataset.columns}
+
+
+def _sync_error_message(ex: Exception) -> str:
+ # Raw SQLAlchemy text can leak SQL or connection details; Superset
+ # exception messages are user-facing by design.
+ if isinstance(ex, SQLAlchemyError):
+ return "a database error occurred"
+ return str(ex)
+
+
+@tool(
+ tags=["mutate"],
+ class_permission_name="Dataset",
+ method_permission_name="write",
+ annotations=ToolAnnotations(
+ title="Update dataset",
+ readOnlyHint=False,
+ # Rewriting a virtual dataset's SQL or re-syncing its columns changes
+ # what every chart built on it queries — non-additive, like
+ # update_chart.
+ destructiveHint=True,
+ idempotentHint=False,
+ openWorldHint=False,
+ ),
+)
+async def update_dataset( # noqa: C901
+ request: UpdateDatasetRequest, ctx: Context
+) -> UpdateDatasetResponse:
+ """Update a dataset's name, SQL, description, default datetime column or
+ cache timeout, and optionally re-sync its columns from the data source.
+
+ Only the properties you pass are changed. ``sql`` applies to virtual
+ datasets only. When ``sql`` changes, columns are re-synced from the new
+ query (like "Sync columns from source" in the dataset editor) unless
+ ``sync_columns`` is false; pass ``sync_columns=true`` on its own to pick
+ up schema changes in the underlying table or query. Calculated columns
+ and saved metrics are kept. Use update_dataset_metric to edit metrics.
+ Requires ownership of the dataset (or Admin).
+
+ Check ``removed_columns`` in the response: charts that use those columns
+ fail until they are updated. ``warnings`` reports problems that did not
+ undo the update, e.g. a column re-sync that failed after the SQL was saved.
+
+ Workflow:
+ 1. Call get_dataset_info to inspect the dataset
+ 2. Call this tool with the dataset ID and only the properties to change
+
+ Example usage:
+ ```json
+ {
+ "dataset_id": 123,
+ "sql": "SELECT region, SUM(revenue) AS revenue FROM sales GROUP BY region",
+ "description": "Revenue by region"
+ }
+ ```
+ """
+ updates = request.updates()
+ await ctx.info(
+ "Updating dataset: dataset_id=%s, properties=%s, sync_columns=%s"
+ % (request.dataset_id, sorted(updates), request.sync_columns)
+ )
+
+ try:
+ from sqlalchemy.orm import joinedload, subqueryload
+
+ from superset.commands.dataset.exceptions import (
+ DatasetForbiddenError,
+ DatasetInvalidError,
+ DatasetNotFoundError,
+ DatasetUpdateFailedError,
+ )
+ from superset.commands.dataset.refresh import RefreshDatasetCommand
+ from superset.commands.dataset.update import UpdateDatasetCommand
+ from superset.connectors.sqla.models import SqlaTable
+ from superset.exceptions import SupersetSecurityException
+ from superset.mcp_service.dataset.dataset_utils import resolve_dataset
+ from superset.mcp_service.utils.url_utils import get_superset_base_url
+
+ eager_options = [
+ subqueryload(SqlaTable.columns),
+ joinedload(SqlaTable.database),
+ ]
+
+ with event_logger.log_context(action="mcp.update_dataset.lookup"):
+ dataset = resolve_dataset(request.dataset_id, eager_options)
+
+ if dataset is None:
+ display_id = str(request.dataset_id)[:200]
+ await ctx.warning("Dataset not found: %s" % (display_id,))
+ return UpdateDatasetResponse(
+ error=(
+ f"No dataset found with identifier: {display_id}."
+ " Use list_datasets to get valid dataset IDs."
+ ),
+ )
+
+ dataset_id = dataset.id
+
+ # Enforce editorship before validating against the dataset's columns,
+ # so a caller without edit rights learns nothing beyond "forbidden".
+ # UpdateDatasetCommand and RefreshDatasetCommand re-check this.
+ try:
+ security_manager.raise_for_editorship(dataset)
+ except SupersetSecurityException:
+ await ctx.warning("Dataset update forbidden: dataset_id=%s" % (dataset_id,))
+ return UpdateDatasetResponse(
+ dataset_id=dataset_id,
+ permission_denied=True,
+ error="You must be an owner of this dataset (or an Admin) to "
+ "update it. Ask the user to update it or grant access; do not "
+ "retry.",
+ )
+
+ if "sql" in updates and not dataset.sql:
+ return UpdateDatasetResponse(
+ dataset_id=dataset_id,
+ error="sql can only be set on a virtual dataset; this dataset "
+ "is a physical table.",
+ )
+
+ sync_columns = (
+ request.sync_columns
+ if request.sync_columns is not None
+ else "sql" in updates and updates["sql"] != dataset.sql
+ )
+
+ # A new default datetime column is checked against the columns the
+ # dataset will have once the update is done: the current ones, or the
+ # re-synced ones, in which case it is applied after the sync.
+ pending_dttm_col = None
+ if updates.get("main_dttm_col") is not None:
+ if sync_columns:
+ pending_dttm_col = updates.pop("main_dttm_col")
+ elif updates["main_dttm_col"] not in _column_names(dataset):
+ dttm_col = updates["main_dttm_col"]
+ return UpdateDatasetResponse(
+ dataset_id=dataset_id,
+ error=f"main_dttm_col '{dttm_col}' is not a column of this "
+ "dataset. Use get_dataset_info to list its columns.",
+ )
+
+ columns_before = _column_names(dataset)
+ updated_properties = sorted(updates)
+
+ if updates:
+ # Same pair of commands as PUT /api/v1/dataset/<pk>?override_columns=
+ # — the update commits before the column refresh runs.
+ with event_logger.log_context(action="mcp.update_dataset.update"):
+ dataset = UpdateDatasetCommand(
+ dataset_id, updates, override_columns=sync_columns
+ ).run()
+
+ warnings: list[str] = []
+ added_columns: list[str] = []
+ removed_columns: list[str] = []
+ columns_synced = False
+ if sync_columns:
+ try:
+ with event_logger.log_context(action="mcp.update_dataset.sync_columns"):
+ dataset = RefreshDatasetCommand(dataset_id).run()
+ columns_after = _column_names(dataset)
+ added_columns = sorted(columns_after - columns_before)
+ removed_columns = sorted(columns_before - columns_after)
+ columns_synced = True
+ except (SupersetException, SQLAlchemyError) as ex:
+ await ctx.warning(
+ "Dataset column sync failed: %s: %s" % (type(ex).__name__, ex)
+ )
+ warnings.append(
+ "The update was saved, but re-syncing columns failed "
+ f"({_sync_error_message(ex)}). The column list may not "
+ "match the dataset's SQL; retry with sync_columns=true."
+ )
+
+ if pending_dttm_col is not None:
+ if not columns_synced:
+ warnings.append(
+ f"main_dttm_col was not changed to '{pending_dttm_col}' "
+ "because the columns could not be re-synced."
+ )
+ elif pending_dttm_col not in _column_names(dataset):
+ warnings.append(
+ f"main_dttm_col was not changed: '{pending_dttm_col}' is not "
+ "a column of the dataset after the update."
+ )
+ else:
+ with event_logger.log_context(action="mcp.update_dataset.update"):
+ dataset = UpdateDatasetCommand(
+ dataset_id, {"main_dttm_col": pending_dttm_col}
+ ).run()
Review Comment:
**Suggestion:** This second update runs after the SQL update and column refresh have committed; if it fails, the tool reports failure although the dataset is already partially changed. [api mismatch]
**Assessment:** 🟠 `Major` · 🔁 `Occurrence: Sometimes`
[](https://docs.codeant.ai/cli/resolve-pr-comments-skill) [](https://app.codeant.ai/fix-in-ide?tool=cursor&prompt_id=b194d473cb3d4d6aadb3a4a20a6bb346&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset) [](https://app.codeant.ai/fix-in-ide?tool=vscode-claude&prompt_id=b194d473cb3d4d6aadb3a4a20a6bb346&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:** superset/mcp_service/dataset/tool/update_dataset.py
**Line:** 229:231
**Comment:**
*Api Mismatch: This second update runs after the SQL update and column refresh have committed; if it fails, the tool reports failure although the dataset is already partially changed.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
```
</details>
<a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F44337&comment_hash=1c987ddfc62c4b321e428f3b5684c0867946b468c87cdf4f435e8f15d19f27d8&reaction=like'>👍</a> | <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F44337&comment_hash=1c987ddfc62c4b321e428f3b5684c0867946b468c87cdf4f435e8f15d19f27d8&reaction=dislike'>👎</a>
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #44337:
URL: https://github.com/apache/superset/pull/44337#issuecomment-5693998156
<!-- Bito Reply -->
The flagged issue is correct. In Python, `bool` is a subclass of `int`, so `cache_timeout=True` is coerced to `1`, which silently sets the cache timeout to 1 second instead of raising a validation error. To resolve this, you should add a field validator to `UpdateDatasetRequest` in `superset/mcp_service/dataset/schemas.py` to explicitly reject boolean values for `cache_timeout`, similar to how `dataset_id` is handled.
Would you like me to implement this fix and check the rest of the PR comments for similar issues?
**superset/mcp_service/dataset/schemas.py**
```
@field_validator("cache_timeout", mode="before")
@classmethod
def reject_bool_cache_timeout(cls, value: object) -> object:
"""bool is a subclass of int, so cache_timeout=true would coerce to
1 second; reject it outright."""
if isinstance(value, bool):
raise ValueError("cache_timeout must be an integer or null")
return value
```
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "Copilot (via GitHub)" <gi...@apache.org>.
Copilot commented on code in PR #44337:
URL: https://github.com/apache/superset/pull/44337#discussion_r4023802389
##########
superset/mcp_service/dataset/tool/restore_dataset.py:
##########
@@ -0,0 +1,196 @@
+# 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.
+
+"""
+MCP tool: restore_dataset
+"""
+
+import logging
+from typing import Any
+
+from fastmcp import Context
+from sqlalchemy.exc import SQLAlchemyError
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.commands.dataset.exceptions import (
+ DatasetForbiddenError,
+ DatasetLogicalDuplicateError,
+ DatasetNotFoundError,
+)
+from superset.commands.exceptions import CommandException
+from superset.extensions import event_logger
+from superset.mcp_service.dataset.schemas import (
+ RestoreDatasetRequest,
+ RestoreDatasetResponse,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _find_dataset_for_restore(identifier: int | str) -> Any | None:
+ """Resolve a dataset by numeric ID or UUID, including soft-deleted rows.
+
+ Both bypasses mirror ``BaseRestoreCommand.validate``'s own lookup:
+ ``skip_visibility_filter`` unhides the soft-deleted row, and
+ ``skip_base_filter`` keeps an editor's own trash reachable even when the
+ dataset's datasource-access base_filter would hide it (a lost grant must
+ not hide a row from the one audience that can restore it). The restore
+ audience is enforced by ``RestoreDatasetCommand`` via
+ ``raise_for_editorship``.
+ """
+ from superset.daos.dataset import DatasetDAO
+
+ return DatasetDAO.find_by_id_or_uuid(
+ str(identifier),
+ skip_base_filter=True,
+ skip_visibility_filter=True,
+ )
+
+
+def _rollback() -> None:
+ from superset import db
+
+ try:
+ db.session.rollback() # pylint: disable=consider-using-transaction
+ except SQLAlchemyError:
+ logger.warning("Database rollback failed during restore_dataset error handling")
+
+
+@tool(
+ tags=["mutate"],
+ class_permission_name="Dataset",
+ annotations=ToolAnnotations(
+ title="Restore dataset",
+ readOnlyHint=False,
+ destructiveHint=False,
+ idempotentHint=False,
+ openWorldHint=False,
+ ),
+)
+async def restore_dataset(
+ request: RestoreDatasetRequest, ctx: Context
+) -> RestoreDatasetResponse:
+ """Restore a soft-deleted dataset from trash.
+
+ Identify the dataset by numeric ID or UUID string (NOT table name). Only
+ datasets that were soft-deleted (moved to trash while the ``SOFT_DELETE``
+ feature flag was enabled) can be restored; permanently deleted datasets
+ are unrecoverable. The caller must be an editor of the dataset (owners
+ and Admins qualify). Use list_datasets with deleted_state='only' to find
+ trashed datasets.
+
+ Restoring fails with ``error_type`` ``LogicalDuplicate`` when another
+ active dataset already points at the same physical table; that dataset
+ must be deleted or renamed first.
+
+ Example:
+ ```json
+ {"identifier": 123}
+ ```
+
+ Returns success with the restored dataset's id/name, or an error. When the
+ caller lacks permission, ``permission_denied`` is true — do not retry; ask
+ the user.
+ """
+ await ctx.info("Restoring dataset: identifier=%s" % (request.identifier,))
+
+ try:
+ dataset = _find_dataset_for_restore(request.identifier)
+ except SQLAlchemyError:
+ _rollback()
+ logger.exception("Dataset lookup failed during restore_dataset")
+ return RestoreDatasetResponse(
+ success=False,
+ error="Dataset lookup failed due to a database error.",
+ error_type="LookupFailed",
+ )
+ if not dataset:
+ display_id = str(request.identifier)[:200]
+ msg = f"No dataset found with identifier: {display_id}."
+ return RestoreDatasetResponse(success=False, error=msg, error_type="NotFound")
+
+ dataset_id = dataset.id
+ # Table names are user-controlled and must remain exact in response text.
+ dataset_name = dataset.table_name
+
+ if dataset.deleted_at is None:
Review Comment:
Because `_find_dataset_for_restore` deliberately bypasses the DAO base filter, this path can reach a dataset that the caller can neither read nor edit. It reads `table_name` and returns `NotDeleted` before any `raise_for_editorship`, and a trashed non-editor receives the name in the `DatasetForbiddenError` response. A custom role with the MCP `Dataset` write permission but no object editorship can therefore enumerate dataset existence/names outside the `SECURITY.md` Read data/own-dataset capability; mirror `restore_chart`/`restore_dashboard`'s pre-response editorship plus visibility check and keep unauthorized rows indistinguishable from `NotFound`.
##########
superset/mcp_service/dataset/tool/update_dataset.py:
##########
@@ -0,0 +1,289 @@
+# 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.
+
+"""
+MCP tool: update_dataset
+"""
+
+import logging
+from typing import Any
+
+from fastmcp import Context
+from sqlalchemy.exc import SQLAlchemyError
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset import security_manager
+from superset.exceptions import SupersetException
+from superset.extensions import event_logger
+from superset.mcp_service.dataset.schemas import (
+ UpdateDatasetRequest,
+ UpdateDatasetResponse,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _column_names(dataset: Any) -> set[str]:
+ return {column.column_name for column in dataset.columns}
+
+
+def _sync_error_message(ex: Exception) -> str:
+ # Raw SQLAlchemy text can leak SQL or connection details; Superset
+ # exception messages are user-facing by design.
+ if isinstance(ex, SQLAlchemyError):
+ return "a database error occurred"
+ return str(ex)
+
+
+@tool(
+ tags=["mutate"],
+ class_permission_name="Dataset",
+ method_permission_name="write",
+ annotations=ToolAnnotations(
+ title="Update dataset",
+ readOnlyHint=False,
+ # Rewriting a virtual dataset's SQL or re-syncing its columns changes
+ # what every chart built on it queries — non-additive, like
+ # update_chart.
+ destructiveHint=True,
+ idempotentHint=False,
+ openWorldHint=False,
+ ),
+)
+async def update_dataset( # noqa: C901
+ request: UpdateDatasetRequest, ctx: Context
+) -> UpdateDatasetResponse:
+ """Update a dataset's name, SQL, description, default datetime column or
+ cache timeout, and optionally re-sync its columns from the data source.
+
+ Only the properties you pass are changed. ``sql`` applies to virtual
+ datasets only. When ``sql`` changes, columns are re-synced from the new
+ query (like "Sync columns from source" in the dataset editor) unless
+ ``sync_columns`` is false; pass ``sync_columns=true`` on its own to pick
+ up schema changes in the underlying table or query. Calculated columns
+ and saved metrics are kept. Use update_dataset_metric to edit metrics.
+ Requires ownership of the dataset (or Admin).
+
+ Check ``removed_columns`` in the response: charts that use those columns
+ fail until they are updated. ``warnings`` reports problems that did not
+ undo the update, e.g. a column re-sync that failed after the SQL was saved.
+
+ Workflow:
+ 1. Call get_dataset_info to inspect the dataset
+ 2. Call this tool with the dataset ID and only the properties to change
+
+ Example usage:
+ ```json
+ {
+ "dataset_id": 123,
+ "sql": "SELECT region, SUM(revenue) AS revenue FROM sales GROUP BY region",
+ "description": "Revenue by region"
+ }
+ ```
+ """
+ updates = request.updates()
+ await ctx.info(
+ "Updating dataset: dataset_id=%s, properties=%s, sync_columns=%s"
+ % (request.dataset_id, sorted(updates), request.sync_columns)
+ )
+
+ try:
+ from sqlalchemy.orm import joinedload, subqueryload
+
+ from superset.commands.dataset.exceptions import (
+ DatasetForbiddenError,
+ DatasetInvalidError,
+ DatasetNotFoundError,
+ DatasetUpdateFailedError,
+ )
+ from superset.commands.dataset.refresh import RefreshDatasetCommand
+ from superset.commands.dataset.update import UpdateDatasetCommand
+ from superset.connectors.sqla.models import SqlaTable
+ from superset.exceptions import SupersetSecurityException
+ from superset.mcp_service.dataset.dataset_utils import resolve_dataset
+ from superset.mcp_service.utils.url_utils import get_superset_base_url
+
+ eager_options = [
+ subqueryload(SqlaTable.columns),
+ joinedload(SqlaTable.database),
+ ]
+
+ with event_logger.log_context(action="mcp.update_dataset.lookup"):
+ dataset = resolve_dataset(request.dataset_id, eager_options)
+
+ if dataset is None:
+ display_id = str(request.dataset_id)[:200]
+ await ctx.warning("Dataset not found: %s" % (display_id,))
+ return UpdateDatasetResponse(
+ error=(
+ f"No dataset found with identifier: {display_id}."
+ " Use list_datasets to get valid dataset IDs."
+ ),
+ )
+
+ dataset_id = dataset.id
+
+ # Enforce editorship before validating against the dataset's columns,
+ # so a caller without edit rights learns nothing beyond "forbidden".
+ # UpdateDatasetCommand and RefreshDatasetCommand re-check this.
+ try:
+ security_manager.raise_for_editorship(dataset)
+ except SupersetSecurityException:
+ await ctx.warning("Dataset update forbidden: dataset_id=%s" % (dataset_id,))
+ return UpdateDatasetResponse(
+ dataset_id=dataset_id,
+ permission_denied=True,
+ error="You must be an owner of this dataset (or an Admin) to "
+ "update it. Ask the user to update it or grant access; do not "
+ "retry.",
+ )
+
+ if "sql" in updates and not dataset.sql:
+ return UpdateDatasetResponse(
+ dataset_id=dataset_id,
+ error="sql can only be set on a virtual dataset; this dataset "
+ "is a physical table.",
+ )
+
+ sync_columns = (
+ request.sync_columns
+ if request.sync_columns is not None
+ else "sql" in updates and updates["sql"] != dataset.sql
+ )
+
+ # A new default datetime column is checked against the columns the
+ # dataset will have once the update is done: the current ones, or the
+ # re-synced ones, in which case it is applied after the sync.
+ pending_dttm_col = None
+ if updates.get("main_dttm_col") is not None:
+ if sync_columns:
+ pending_dttm_col = updates.pop("main_dttm_col")
+ elif updates["main_dttm_col"] not in _column_names(dataset):
+ dttm_col = updates["main_dttm_col"]
+ return UpdateDatasetResponse(
+ dataset_id=dataset_id,
+ error=f"main_dttm_col '{dttm_col}' is not a column of this "
+ "dataset. Use get_dataset_info to list its columns.",
+ )
+
+ columns_before = _column_names(dataset)
+ updated_properties = sorted(updates)
+
+ if updates:
+ # Same pair of commands as PUT /api/v1/dataset/<pk>?override_columns=
+ # — the update commits before the column refresh runs.
+ with event_logger.log_context(action="mcp.update_dataset.update"):
+ dataset = UpdateDatasetCommand(
+ dataset_id, updates, override_columns=sync_columns
+ ).run()
+
+ warnings: list[str] = []
+ added_columns: list[str] = []
+ removed_columns: list[str] = []
+ columns_synced = False
+ if sync_columns:
+ try:
+ with event_logger.log_context(action="mcp.update_dataset.sync_columns"):
+ dataset = RefreshDatasetCommand(dataset_id).run()
+ columns_after = _column_names(dataset)
+ added_columns = sorted(columns_after - columns_before)
+ removed_columns = sorted(columns_before - columns_after)
+ columns_synced = True
Review Comment:
`RefreshDatasetCommand.run()` deliberately catches `SupersetVirtualTableParseException` and returns the unchanged model, so a Jinja/template parse failure does not raise into this block. The code then compares the old column set and sets `columns_synced=True` without a warning, even though no re-sync occurred (and a pending `main_dttm_col` can be applied against stale columns). Expose the skipped-refresh status or propagate that exception here so the response matches the documented warning semantics.
##########
tests/unit_tests/mcp_service/dataset/tool/test_dataset_tools.py:
##########
@@ -97,6 +97,7 @@ def create_mock_dataset(
dataset.template_params = {}
dataset.extra = {}
dataset.uuid = f"test-dataset-uuid-{dataset_id}"
+ dataset.deleted_at = None
Review Comment:
This only updates the shared fixture. The same file still has direct `MagicMock` datasets in the get-info/list tests (for example lines 1029, 1170, 1261, and 1340) that are passed through `serialize_dataset_object` without setting `deleted_at`; `getattr` returns a child `MagicMock`, which Pydantic cannot validate as `str | datetime | None`. Add `deleted_at = None` to each direct fixture, otherwise `pytest tests/unit_tests/mcp_service/dataset` will fail.
##########
superset/mcp_service/dataset/tool/update_dataset.py:
##########
@@ -0,0 +1,289 @@
+# 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.
+
+"""
+MCP tool: update_dataset
+"""
+
+import logging
+from typing import Any
+
+from fastmcp import Context
+from sqlalchemy.exc import SQLAlchemyError
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset import security_manager
+from superset.exceptions import SupersetException
+from superset.extensions import event_logger
+from superset.mcp_service.dataset.schemas import (
+ UpdateDatasetRequest,
+ UpdateDatasetResponse,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _column_names(dataset: Any) -> set[str]:
+ return {column.column_name for column in dataset.columns}
+
+
+def _sync_error_message(ex: Exception) -> str:
+ # Raw SQLAlchemy text can leak SQL or connection details; Superset
+ # exception messages are user-facing by design.
+ if isinstance(ex, SQLAlchemyError):
+ return "a database error occurred"
+ return str(ex)
+
+
+@tool(
+ tags=["mutate"],
+ class_permission_name="Dataset",
+ method_permission_name="write",
+ annotations=ToolAnnotations(
+ title="Update dataset",
+ readOnlyHint=False,
+ # Rewriting a virtual dataset's SQL or re-syncing its columns changes
+ # what every chart built on it queries — non-additive, like
+ # update_chart.
+ destructiveHint=True,
+ idempotentHint=False,
+ openWorldHint=False,
+ ),
+)
+async def update_dataset( # noqa: C901
+ request: UpdateDatasetRequest, ctx: Context
+) -> UpdateDatasetResponse:
+ """Update a dataset's name, SQL, description, default datetime column or
+ cache timeout, and optionally re-sync its columns from the data source.
+
+ Only the properties you pass are changed. ``sql`` applies to virtual
+ datasets only. When ``sql`` changes, columns are re-synced from the new
+ query (like "Sync columns from source" in the dataset editor) unless
+ ``sync_columns`` is false; pass ``sync_columns=true`` on its own to pick
+ up schema changes in the underlying table or query. Calculated columns
+ and saved metrics are kept. Use update_dataset_metric to edit metrics.
+ Requires ownership of the dataset (or Admin).
+
+ Check ``removed_columns`` in the response: charts that use those columns
+ fail until they are updated. ``warnings`` reports problems that did not
+ undo the update, e.g. a column re-sync that failed after the SQL was saved.
+
+ Workflow:
+ 1. Call get_dataset_info to inspect the dataset
+ 2. Call this tool with the dataset ID and only the properties to change
+
+ Example usage:
+ ```json
+ {
+ "dataset_id": 123,
+ "sql": "SELECT region, SUM(revenue) AS revenue FROM sales GROUP BY region",
+ "description": "Revenue by region"
+ }
+ ```
+ """
+ updates = request.updates()
+ await ctx.info(
+ "Updating dataset: dataset_id=%s, properties=%s, sync_columns=%s"
+ % (request.dataset_id, sorted(updates), request.sync_columns)
+ )
+
+ try:
+ from sqlalchemy.orm import joinedload, subqueryload
+
+ from superset.commands.dataset.exceptions import (
+ DatasetForbiddenError,
+ DatasetInvalidError,
+ DatasetNotFoundError,
+ DatasetUpdateFailedError,
+ )
+ from superset.commands.dataset.refresh import RefreshDatasetCommand
+ from superset.commands.dataset.update import UpdateDatasetCommand
+ from superset.connectors.sqla.models import SqlaTable
+ from superset.exceptions import SupersetSecurityException
+ from superset.mcp_service.dataset.dataset_utils import resolve_dataset
+ from superset.mcp_service.utils.url_utils import get_superset_base_url
+
+ eager_options = [
+ subqueryload(SqlaTable.columns),
+ joinedload(SqlaTable.database),
+ ]
+
+ with event_logger.log_context(action="mcp.update_dataset.lookup"):
+ dataset = resolve_dataset(request.dataset_id, eager_options)
+
+ if dataset is None:
+ display_id = str(request.dataset_id)[:200]
+ await ctx.warning("Dataset not found: %s" % (display_id,))
+ return UpdateDatasetResponse(
+ error=(
+ f"No dataset found with identifier: {display_id}."
+ " Use list_datasets to get valid dataset IDs."
+ ),
+ )
+
+ dataset_id = dataset.id
+
+ # Enforce editorship before validating against the dataset's columns,
+ # so a caller without edit rights learns nothing beyond "forbidden".
+ # UpdateDatasetCommand and RefreshDatasetCommand re-check this.
+ try:
+ security_manager.raise_for_editorship(dataset)
+ except SupersetSecurityException:
+ await ctx.warning("Dataset update forbidden: dataset_id=%s" % (dataset_id,))
+ return UpdateDatasetResponse(
+ dataset_id=dataset_id,
+ permission_denied=True,
+ error="You must be an owner of this dataset (or an Admin) to "
+ "update it. Ask the user to update it or grant access; do not "
+ "retry.",
+ )
+
+ if "sql" in updates and not dataset.sql:
+ return UpdateDatasetResponse(
+ dataset_id=dataset_id,
+ error="sql can only be set on a virtual dataset; this dataset "
+ "is a physical table.",
+ )
+
+ sync_columns = (
+ request.sync_columns
+ if request.sync_columns is not None
+ else "sql" in updates and updates["sql"] != dataset.sql
+ )
+
+ # A new default datetime column is checked against the columns the
+ # dataset will have once the update is done: the current ones, or the
+ # re-synced ones, in which case it is applied after the sync.
+ pending_dttm_col = None
+ if updates.get("main_dttm_col") is not None:
+ if sync_columns:
+ pending_dttm_col = updates.pop("main_dttm_col")
+ elif updates["main_dttm_col"] not in _column_names(dataset):
+ dttm_col = updates["main_dttm_col"]
+ return UpdateDatasetResponse(
+ dataset_id=dataset_id,
+ error=f"main_dttm_col '{dttm_col}' is not a column of this "
+ "dataset. Use get_dataset_info to list its columns.",
+ )
+
+ columns_before = _column_names(dataset)
+ updated_properties = sorted(updates)
+
+ if updates:
+ # Same pair of commands as PUT /api/v1/dataset/<pk>?override_columns=
+ # — the update commits before the column refresh runs.
+ with event_logger.log_context(action="mcp.update_dataset.update"):
+ dataset = UpdateDatasetCommand(
+ dataset_id, updates, override_columns=sync_columns
+ ).run()
+
+ warnings: list[str] = []
+ added_columns: list[str] = []
+ removed_columns: list[str] = []
+ columns_synced = False
+ if sync_columns:
+ try:
+ with event_logger.log_context(action="mcp.update_dataset.sync_columns"):
+ dataset = RefreshDatasetCommand(dataset_id).run()
+ columns_after = _column_names(dataset)
+ added_columns = sorted(columns_after - columns_before)
+ removed_columns = sorted(columns_before - columns_after)
+ columns_synced = True
+ except (SupersetException, SQLAlchemyError) as ex:
+ await ctx.warning(
+ "Dataset column sync failed: %s: %s" % (type(ex).__name__, ex)
+ )
+ warnings.append(
+ "The update was saved, but re-syncing columns failed "
+ f"({_sync_error_message(ex)}). The column list may not "
+ "match the dataset's SQL; retry with sync_columns=true."
+ )
+
+ if pending_dttm_col is not None:
+ if not columns_synced:
+ warnings.append(
+ f"main_dttm_col was not changed to '{pending_dttm_col}' "
+ "because the columns could not be re-synced."
+ )
+ elif pending_dttm_col not in _column_names(dataset):
+ warnings.append(
+ f"main_dttm_col was not changed: '{pending_dttm_col}' is not "
+ "a column of the dataset after the update."
Review Comment:
When `sql` and `main_dttm_col` are supplied together, a target column that is absent after the refresh takes this warning branch and the tool still returns a successful update. That contradicts the stated contract that setting a nonexistent `main_dttm_col` is rejected, and the caller may believe the requested property was applied even though it was not; validate this deferred value as a failure (or redesign the transaction so it can be rejected before saving the SQL).
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #44337:
URL: https://github.com/apache/superset/pull/44337#issuecomment-5713790714
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
<details>
<summary>
Bito Usage Guide
</summary>
**Commands**
Type the following command in the pull request comment and save the comment.
- `/review` - Manually triggers an incremental AI Review.
- `/review full` - Manually triggers a full AI Review.
- `/pause` - Pauses automatic reviews on this pull request.
- `/resume` - Resumes automatic reviews.
- `/resolve` - Marks all Bito-posted review comments as resolved.
- `/abort` - Cancels all in-progress reviews.
Refer to the <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/available-commands" target="_blank" rel="noopener noreferrer">documentation</a> for additional commands.
**Configuration**
This repository uses `Superset` You can customize the agent settings <a href="https://alpha.bito.ai/home/ai-agents/code-review-agent" target="_blank" rel="noopener noreferrer">here</a> or contact your Bito workspace admin at evan@preset.io.
**Documentation & Help**
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/getting-started/install-run-using-bito-cloud/create-or-customize-an-agent-instance" target="_blank" rel="noopener noreferrer">Customize agent settings</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/implementing-custom-code-review-rules" target="_blank" rel="noopener noreferrer">Review rules</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent" target="_blank" rel="noopener noreferrer">General documentation</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/faqs" target="_blank" rel="noopener noreferrer">FAQ</a>
</details>
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "tien238lnd (via GitHub)" <gi...@apache.org>.
tien238lnd commented on PR #44337:
URL: https://github.com/apache/superset/pull/44337#issuecomment-5827944021
Pushed 1e4ef6938 and swept the open threads.
- `update_dataset` now accepts `identifier` as well as `dataset_id` (bito's naming note), matching how `GetDatasetInfoRequest` already takes either. A rename would have broken callers, and `dataset_id` is what `update_dataset_metric` uses on master.
- The restore test's `DatasetForbiddenError` import moved to module level.
- One review note does not hold: `delete_dataset`/`restore_dataset` are not gated by `can_read`. The permission comes from the tool's tags — `core_mcp_injection.py` sets `"write" if "mutate" in tool_tags else "read"` before the auth hook wraps the function — and both declare `tags=["mutate"]`. The `auth.py` default only applies when neither a tag nor an explicit name supplies one, and the rbac cases pass at this head.
- Declined, each with a reason in its thread: annotating obvious test locals, replacing two `side_effect` lambdas with named helpers, and splitting `update_dataset` into helpers. On Copilot's deferred `main_dttm_col`: it stays a warning because by then the SQL update has committed, so a failure would report the opposite of what happened; the field is excluded from `updated_properties` and named in the warning.
Everything else in the queue was already fixed in `a7739e4aa9`/`9ded2f2adc`/`4321f8075`; those threads are resolved. Amin's note about coordinating with #44573 stays open on purpose — `UpdateDatasetRequest` takes the shared base once one of the two lands.
Locally: 339 passed across `tests/unit_tests/mcp_service/dataset`, the rbac suite and the registration suite, with only `test_query_dataset_reexecutes_across_rollover` failing, which fails the same way on the merge base here.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #44337:
URL: https://github.com/apache/superset/pull/44337#issuecomment-5864001323
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
<details>
<summary>
Bito Usage Guide
</summary>
**Commands**
Type the following command in the pull request comment and save the comment.
- `/review` - Manually triggers an incremental AI Review.
- `/review full` - Manually triggers a full AI Review.
- `/pause` - Pauses automatic reviews on this pull request.
- `/resume` - Resumes automatic reviews.
- `/resolve` - Marks all Bito-posted review comments as resolved.
- `/abort` - Cancels all in-progress reviews.
Refer to the <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/available-commands" target="_blank" rel="noopener noreferrer">documentation</a> for additional commands.
**Configuration**
This repository uses `Superset` You can customize the agent settings <a href="https://alpha.bito.ai/home/ai-agents/code-review-agent" target="_blank" rel="noopener noreferrer">here</a> or contact your Bito workspace admin at evan@preset.io.
**Documentation & Help**
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/getting-started/install-run-using-bito-cloud/create-or-customize-an-agent-instance" target="_blank" rel="noopener noreferrer">Customize agent settings</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/implementing-custom-code-review-rules" target="_blank" rel="noopener noreferrer">Review rules</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent" target="_blank" rel="noopener noreferrer">General documentation</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/faqs" target="_blank" rel="noopener noreferrer">FAQ</a>
</details>
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #44337:
URL: https://github.com/apache/superset/pull/44337#issuecomment-5693908805
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "arcanalytics (via GitHub)" <gi...@apache.org>.
arcanalytics commented on code in PR #44337:
URL: https://github.com/apache/superset/pull/44337#discussion_r4037404936
##########
tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py:
##########
@@ -0,0 +1,336 @@
+# 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 the delete_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_RESOLVE = "superset.mcp_service.dataset.tool.delete_dataset.resolve_dataset"
+_COUNT = "superset.mcp_service.dataset.tool.delete_dataset._count_affected_objects"
+_RUN = "superset.commands.dataset.delete.DeleteDatasetCommand.run"
+_VALIDATE = "superset.commands.dataset.delete.DeleteDatasetCommand.validate"
+_FLAG = "superset.mcp_service.dataset.tool.delete_dataset.is_feature_enabled"
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ """Provide the FastMCP app instance under test."""
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ """Authenticate every tool call as a mock admin user."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool validates the command (lookup + editorship) before counting
+ # dependents; default it to a pass so tests that patch run() alone keep
+ # working. The permission test re-patches it to raise.
+ with patch(_VALIDATE):
+ mock_user = Mock()
Review Comment:
All six findings on this model shared one root cause, so `00085fe2a73` removes the cause rather than patching each site.
The model claimed to reconstruct the cohort as of a past date while reading current-state sources, which is why every round surfaced another input that was not historical. Since a flight cuts exactly once and the freshness gate makes it cut only when data is complete, that as-of machinery was buying reproducibility the model caveat already disclaimed. It is gone, and the description now says plainly that the cohort is a snapshot taken at cut time, made durable by writing once rather than by being replayable. The enrolment query is 216 lines shorter.
Specifically:
- Qualification gates on `is_winback_eligible`, the governed flag that already covers fraud, ban, current-subscriber and consent. That closes the missing fraud check and removes the hand-rolled ban and consent CTEs.
- The freshness gate now covers `warehouse_internal.page_events`, the source the dormancy rule reads that it had missed, using a bounded seven-day lookback so the watermark scan stays cheap.
- The MRR window inconsistency is gone: there is no per-day MRR read left, only peak value ever, with current-subscriber status coming from the governed flag.
- `last_billing_country` is read at cut time and the description no longer claims otherwise.
- Arm assignment applies `abs()` outside `mod()`, so a fingerprint of INT64_MIN cannot fail the build.
Verified on prod: run one enrolled 1,424,343 users, an identical second run added 0, and all rows are distinct users. Delivery output is unchanged at 124,152 churned and 1,014,899 lapsed rows, because the users this removes were previously enrolled and then suppressed, so they never shipped.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "tien238lnd (via GitHub)" <gi...@apache.org>.
tien238lnd commented on PR #44337:
URL: https://github.com/apache/superset/pull/44337#issuecomment-5792797015
Rebased onto master, which had gone stale: `list_datasets` now passes `custom_filters or None`, and the `deleted_state` argument sits next to it.
Master also gained `test_committed_write_specs_cover_every_committing_mutating_tool` while this was open, so 47ee13df6f registers the three tools in `COMMITTED_WRITE_SPECS`. `delete_dataset` and `restore_dataset` confirm the write through scalars and need no protected containers; `update_dataset`'s confirmation is the `updated_properties` list.
`pytest tests/unit_tests/mcp_service/dataset tests/unit_tests/mcp_service/test_mcp_tool_registration.py tests/unit_tests/mcp_service/test_rbac_tool_enforcement.py tests/unit_tests/mcp_service/test_middleware.py`: green apart from the two `test_query_dataset.py` failures that also fail on master here.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #44337:
URL: https://github.com/apache/superset/pull/44337#issuecomment-5795361576
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
<details>
<summary>
Bito Usage Guide
</summary>
**Commands**
Type the following command in the pull request comment and save the comment.
- `/review` - Manually triggers an incremental AI Review.
- `/review full` - Manually triggers a full AI Review.
- `/pause` - Pauses automatic reviews on this pull request.
- `/resume` - Resumes automatic reviews.
- `/resolve` - Marks all Bito-posted review comments as resolved.
- `/abort` - Cancels all in-progress reviews.
Refer to the <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/available-commands" target="_blank" rel="noopener noreferrer">documentation</a> for additional commands.
**Configuration**
This repository uses `Superset` You can customize the agent settings <a href="https://alpha.bito.ai/home/ai-agents/code-review-agent" target="_blank" rel="noopener noreferrer">here</a> or contact your Bito workspace admin at evan@preset.io.
**Documentation & Help**
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/getting-started/install-run-using-bito-cloud/create-or-customize-an-agent-instance" target="_blank" rel="noopener noreferrer">Customize agent settings</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/implementing-custom-code-review-rules" target="_blank" rel="noopener noreferrer">Review rules</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent" target="_blank" rel="noopener noreferrer">General documentation</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/faqs" target="_blank" rel="noopener noreferrer">FAQ</a>
</details>
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "tien238lnd (via GitHub)" <gi...@apache.org>.
tien238lnd commented on code in PR #44337:
URL: https://github.com/apache/superset/pull/44337#discussion_r4101778164
##########
superset/mcp_service/dataset/tool/update_dataset.py:
##########
@@ -0,0 +1,289 @@
+# 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.
+
+"""
+MCP tool: update_dataset
+"""
+
+import logging
+from typing import Any
+
+from fastmcp import Context
+from sqlalchemy.exc import SQLAlchemyError
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset import security_manager
+from superset.exceptions import SupersetException
+from superset.extensions import event_logger
+from superset.mcp_service.dataset.schemas import (
+ UpdateDatasetRequest,
+ UpdateDatasetResponse,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _column_names(dataset: Any) -> set[str]:
+ return {column.column_name for column in dataset.columns}
+
+
+def _sync_error_message(ex: Exception) -> str:
+ # Raw SQLAlchemy text can leak SQL or connection details; Superset
+ # exception messages are user-facing by design.
+ if isinstance(ex, SQLAlchemyError):
+ return "a database error occurred"
+ return str(ex)
+
+
+@tool(
+ tags=["mutate"],
+ class_permission_name="Dataset",
+ method_permission_name="write",
+ annotations=ToolAnnotations(
+ title="Update dataset",
+ readOnlyHint=False,
+ # Rewriting a virtual dataset's SQL or re-syncing its columns changes
+ # what every chart built on it queries — non-additive, like
+ # update_chart.
+ destructiveHint=True,
+ idempotentHint=False,
+ openWorldHint=False,
+ ),
+)
+async def update_dataset( # noqa: C901
+ request: UpdateDatasetRequest, ctx: Context
+) -> UpdateDatasetResponse:
+ """Update a dataset's name, SQL, description, default datetime column or
+ cache timeout, and optionally re-sync its columns from the data source.
+
+ Only the properties you pass are changed. ``sql`` applies to virtual
+ datasets only. When ``sql`` changes, columns are re-synced from the new
+ query (like "Sync columns from source" in the dataset editor) unless
+ ``sync_columns`` is false; pass ``sync_columns=true`` on its own to pick
+ up schema changes in the underlying table or query. Calculated columns
+ and saved metrics are kept. Use update_dataset_metric to edit metrics.
+ Requires ownership of the dataset (or Admin).
+
+ Check ``removed_columns`` in the response: charts that use those columns
+ fail until they are updated. ``warnings`` reports problems that did not
+ undo the update, e.g. a column re-sync that failed after the SQL was saved.
+
+ Workflow:
+ 1. Call get_dataset_info to inspect the dataset
+ 2. Call this tool with the dataset ID and only the properties to change
+
+ Example usage:
+ ```json
+ {
+ "dataset_id": 123,
+ "sql": "SELECT region, SUM(revenue) AS revenue FROM sales GROUP BY region",
+ "description": "Revenue by region"
+ }
+ ```
+ """
+ updates = request.updates()
+ await ctx.info(
+ "Updating dataset: dataset_id=%s, properties=%s, sync_columns=%s"
+ % (request.dataset_id, sorted(updates), request.sync_columns)
+ )
+
+ try:
+ from sqlalchemy.orm import joinedload, subqueryload
+
+ from superset.commands.dataset.exceptions import (
+ DatasetForbiddenError,
+ DatasetInvalidError,
+ DatasetNotFoundError,
+ DatasetUpdateFailedError,
+ )
+ from superset.commands.dataset.refresh import RefreshDatasetCommand
+ from superset.commands.dataset.update import UpdateDatasetCommand
+ from superset.connectors.sqla.models import SqlaTable
+ from superset.exceptions import SupersetSecurityException
+ from superset.mcp_service.dataset.dataset_utils import resolve_dataset
+ from superset.mcp_service.utils.url_utils import get_superset_base_url
+
+ eager_options = [
+ subqueryload(SqlaTable.columns),
+ joinedload(SqlaTable.database),
+ ]
+
+ with event_logger.log_context(action="mcp.update_dataset.lookup"):
+ dataset = resolve_dataset(request.dataset_id, eager_options)
+
+ if dataset is None:
+ display_id = str(request.dataset_id)[:200]
+ await ctx.warning("Dataset not found: %s" % (display_id,))
+ return UpdateDatasetResponse(
+ error=(
+ f"No dataset found with identifier: {display_id}."
+ " Use list_datasets to get valid dataset IDs."
+ ),
+ )
+
+ dataset_id = dataset.id
+
+ # Enforce editorship before validating against the dataset's columns,
+ # so a caller without edit rights learns nothing beyond "forbidden".
+ # UpdateDatasetCommand and RefreshDatasetCommand re-check this.
+ try:
+ security_manager.raise_for_editorship(dataset)
+ except SupersetSecurityException:
+ await ctx.warning("Dataset update forbidden: dataset_id=%s" % (dataset_id,))
+ return UpdateDatasetResponse(
+ dataset_id=dataset_id,
+ permission_denied=True,
+ error="You must be an owner of this dataset (or an Admin) to "
+ "update it. Ask the user to update it or grant access; do not "
+ "retry.",
+ )
+
+ if "sql" in updates and not dataset.sql:
+ return UpdateDatasetResponse(
+ dataset_id=dataset_id,
+ error="sql can only be set on a virtual dataset; this dataset "
+ "is a physical table.",
+ )
+
+ sync_columns = (
+ request.sync_columns
+ if request.sync_columns is not None
+ else "sql" in updates and updates["sql"] != dataset.sql
+ )
+
+ # A new default datetime column is checked against the columns the
+ # dataset will have once the update is done: the current ones, or the
+ # re-synced ones, in which case it is applied after the sync.
+ pending_dttm_col = None
+ if updates.get("main_dttm_col") is not None:
+ if sync_columns:
+ pending_dttm_col = updates.pop("main_dttm_col")
+ elif updates["main_dttm_col"] not in _column_names(dataset):
+ dttm_col = updates["main_dttm_col"]
+ return UpdateDatasetResponse(
+ dataset_id=dataset_id,
+ error=f"main_dttm_col '{dttm_col}' is not a column of this "
+ "dataset. Use get_dataset_info to list its columns.",
+ )
+
+ columns_before = _column_names(dataset)
+ updated_properties = sorted(updates)
+
+ if updates:
+ # Same pair of commands as PUT /api/v1/dataset/<pk>?override_columns=
+ # — the update commits before the column refresh runs.
+ with event_logger.log_context(action="mcp.update_dataset.update"):
+ dataset = UpdateDatasetCommand(
+ dataset_id, updates, override_columns=sync_columns
+ ).run()
+
+ warnings: list[str] = []
+ added_columns: list[str] = []
+ removed_columns: list[str] = []
+ columns_synced = False
+ if sync_columns:
+ try:
+ with event_logger.log_context(action="mcp.update_dataset.sync_columns"):
+ dataset = RefreshDatasetCommand(dataset_id).run()
+ columns_after = _column_names(dataset)
+ added_columns = sorted(columns_after - columns_before)
+ removed_columns = sorted(columns_before - columns_after)
+ columns_synced = True
Review Comment:
Fixed: `RefreshDatasetCommand` now exposes `metadata_refreshed`, and a refresh it skipped is reported as a warning with `columns_synced` left false, so the response matches what happened.
_🤖 Addressed by [Claude Code](https://claude.com/claude-code)_
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on code in PR #44337:
URL: https://github.com/apache/superset/pull/44337#discussion_r4036857689
##########
superset/mcp_service/dataset/tool/update_dataset.py:
##########
@@ -0,0 +1,320 @@
+# 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.
+
+"""
+MCP tool: update_dataset
+"""
+
+import logging
+from typing import Any
+
+from fastmcp import Context
+from sqlalchemy.exc import SQLAlchemyError
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset import security_manager
+from superset.exceptions import SupersetException
+from superset.extensions import event_logger
+from superset.mcp_service.dataset.schemas import (
+ UpdateDatasetRequest,
+ UpdateDatasetResponse,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _column_names(dataset: Any) -> set[str]:
+ return {column.column_name for column in dataset.columns}
+
+
+def _sync_error_message(ex: Exception) -> str:
+ # Raw SQLAlchemy text can leak SQL or connection details; Superset
+ # exception messages are user-facing by design.
+ if isinstance(ex, SQLAlchemyError):
+ return "a database error occurred"
+ return str(ex)
+
+
+@tool(
+ tags=["mutate"],
+ class_permission_name="Dataset",
+ method_permission_name="write",
+ annotations=ToolAnnotations(
+ title="Update dataset",
+ readOnlyHint=False,
+ # Rewriting a virtual dataset's SQL or re-syncing its columns changes
+ # what every chart built on it queries — non-additive, like
+ # update_chart.
+ destructiveHint=True,
+ idempotentHint=False,
+ openWorldHint=False,
+ ),
+)
+async def update_dataset( # noqa: C901
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Overloaded tool function</b></div>
<div id="fix">
`update_dataset` is ~250 lines with `# noqa: C901`, combining identifier resolution, editorship enforcement, sql-vs-physical validation, sync_columns computation, main_dttm_col validation/application, column re-sync, and response building. Multiple unrelated responsibilities in one unit (dim 14) make it hard to test and maintain.
</div>
</div>
<div><details><summary><b>Review Rule</b></summary><div>Bito created the rule <strong><code>Cohesive Sequential Workflow Exception to Single Responsibility</code></strong> for <strong>repo:</strong> <code>apache/superset</code>, <strong>language:</strong> <code>Python</code>. Bito will avoid suggestions that match this rule. You can manage review rules <a href=https://alpha.bito.ai/home/ai-agents/review-rules>here</a>.</div></details></div>
<small><i>Code Review Run #81edb1</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "tien238lnd (via GitHub)" <gi...@apache.org>.
tien238lnd commented on PR #44337:
URL: https://github.com/apache/superset/pull/44337#issuecomment-5862351150
Fixed in 2a4a2e0d1. The two failures came from `tests/unit_tests/mcp_service/test_tool_inventory.py`, which arrived with the merge and did not exist when this branch was written.
- It requires an explicit budget for every registered tool, so `delete_dataset` (959 bytes → 1,100), `restore_dataset` (984 → 1,100) and `update_dataset` (2,190 → 2,300) get theirs by the convention stated in the file: measured bytes rounded up to the next hundred, plus a hundred of headroom.
- `list_datasets` is at 4,949 bytes against a 4,600 limit, because this PR gives it the same `deleted_state` edit/restore and under-enumeration caveats that `list_charts` and `list_dashboards` already carry. Raised to 5,100 and noted in the comment next to those two, so the reason stays with the number.
`test_tool_inventory.py` is 156 passed locally, including `test_tool_inventory_preserves_complete_schema` for the three new tools.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "aminghadersohi (via GitHub)" <gi...@apache.org>.
aminghadersohi commented on code in PR #44337:
URL: https://github.com/apache/superset/pull/44337#discussion_r4038165949
##########
tests/unit_tests/mcp_service/test_rbac_tool_enforcement.py:
##########
@@ -110,6 +110,24 @@
"write",
"Dataset",
),
+ (
+ "update_dataset",
+ {"dataset_id": 1, "description": "denied"},
+ "write",
+ "Dataset",
+ ),
+ (
+ "delete_dataset",
+ {"identifier": 1},
+ "write",
+ "Dataset",
+ ),
+ (
+ "restore_dataset",
+ {"identifier": 1},
+ "write",
+ "Dataset",
+ ),
Review Comment:
Doesn't reproduce at `a7739e4aa9`. `superset/core/mcp/core_mcp_injection.py:205-207` sets `_method_permission_name` to `"write" if "mutate" in tool_tags else "read"` *before* `mcp_auth_hook` wraps the tool, so the `getattr(..., "read")` fallback at `auth.py:397` never fires for these — all three declare `tags=["mutate"]`. Running the file: 5 selected, 5 passed, 0 skipped, `delete_dataset` and `restore_dataset` included. `generate_chart` is a pre-existing entry in the same list with the identical shape (no `method_permission_name`, expects `write`).
Flagging that the second half of the suggestion — aligning the test expectation — would downgrade these two destructive tools to asserting `can_read`.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "tien238lnd (via GitHub)" <gi...@apache.org>.
tien238lnd commented on code in PR #44337:
URL: https://github.com/apache/superset/pull/44337#discussion_r4034831770
##########
tests/unit_tests/mcp_service/dataset/tool/test_dataset_tools.py:
##########
@@ -97,6 +97,7 @@ def create_mock_dataset(
dataset.template_params = {}
dataset.extra = {}
dataset.uuid = f"test-dataset-uuid-{dataset_id}"
+ dataset.deleted_at = None
Review Comment:
I checked this: `pytest tests/unit_tests/mcp_service/dataset` passes with the change, on CI and locally, including the tests that build `MagicMock` datasets directly. `DatasetInfo` accepts the `deleted_at` attribute those fixtures produce, so they need no change.
##########
superset/mcp_service/dataset/tool/update_dataset.py:
##########
@@ -0,0 +1,289 @@
+# 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.
+
+"""
+MCP tool: update_dataset
+"""
+
+import logging
+from typing import Any
+
+from fastmcp import Context
+from sqlalchemy.exc import SQLAlchemyError
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset import security_manager
+from superset.exceptions import SupersetException
+from superset.extensions import event_logger
+from superset.mcp_service.dataset.schemas import (
+ UpdateDatasetRequest,
+ UpdateDatasetResponse,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _column_names(dataset: Any) -> set[str]:
+ return {column.column_name for column in dataset.columns}
+
+
+def _sync_error_message(ex: Exception) -> str:
+ # Raw SQLAlchemy text can leak SQL or connection details; Superset
+ # exception messages are user-facing by design.
+ if isinstance(ex, SQLAlchemyError):
+ return "a database error occurred"
+ return str(ex)
+
+
+@tool(
+ tags=["mutate"],
+ class_permission_name="Dataset",
+ method_permission_name="write",
+ annotations=ToolAnnotations(
+ title="Update dataset",
+ readOnlyHint=False,
+ # Rewriting a virtual dataset's SQL or re-syncing its columns changes
+ # what every chart built on it queries — non-additive, like
+ # update_chart.
+ destructiveHint=True,
+ idempotentHint=False,
+ openWorldHint=False,
+ ),
+)
+async def update_dataset( # noqa: C901
+ request: UpdateDatasetRequest, ctx: Context
+) -> UpdateDatasetResponse:
+ """Update a dataset's name, SQL, description, default datetime column or
+ cache timeout, and optionally re-sync its columns from the data source.
+
+ Only the properties you pass are changed. ``sql`` applies to virtual
+ datasets only. When ``sql`` changes, columns are re-synced from the new
+ query (like "Sync columns from source" in the dataset editor) unless
+ ``sync_columns`` is false; pass ``sync_columns=true`` on its own to pick
+ up schema changes in the underlying table or query. Calculated columns
+ and saved metrics are kept. Use update_dataset_metric to edit metrics.
+ Requires ownership of the dataset (or Admin).
+
+ Check ``removed_columns`` in the response: charts that use those columns
+ fail until they are updated. ``warnings`` reports problems that did not
+ undo the update, e.g. a column re-sync that failed after the SQL was saved.
+
+ Workflow:
+ 1. Call get_dataset_info to inspect the dataset
+ 2. Call this tool with the dataset ID and only the properties to change
+
+ Example usage:
+ ```json
+ {
+ "dataset_id": 123,
+ "sql": "SELECT region, SUM(revenue) AS revenue FROM sales GROUP BY region",
+ "description": "Revenue by region"
+ }
+ ```
+ """
+ updates = request.updates()
+ await ctx.info(
+ "Updating dataset: dataset_id=%s, properties=%s, sync_columns=%s"
+ % (request.dataset_id, sorted(updates), request.sync_columns)
+ )
+
+ try:
+ from sqlalchemy.orm import joinedload, subqueryload
+
+ from superset.commands.dataset.exceptions import (
+ DatasetForbiddenError,
+ DatasetInvalidError,
+ DatasetNotFoundError,
+ DatasetUpdateFailedError,
+ )
+ from superset.commands.dataset.refresh import RefreshDatasetCommand
+ from superset.commands.dataset.update import UpdateDatasetCommand
+ from superset.connectors.sqla.models import SqlaTable
+ from superset.exceptions import SupersetSecurityException
+ from superset.mcp_service.dataset.dataset_utils import resolve_dataset
+ from superset.mcp_service.utils.url_utils import get_superset_base_url
+
+ eager_options = [
+ subqueryload(SqlaTable.columns),
+ joinedload(SqlaTable.database),
+ ]
+
+ with event_logger.log_context(action="mcp.update_dataset.lookup"):
+ dataset = resolve_dataset(request.dataset_id, eager_options)
+
+ if dataset is None:
+ display_id = str(request.dataset_id)[:200]
+ await ctx.warning("Dataset not found: %s" % (display_id,))
+ return UpdateDatasetResponse(
+ error=(
+ f"No dataset found with identifier: {display_id}."
+ " Use list_datasets to get valid dataset IDs."
+ ),
+ )
+
+ dataset_id = dataset.id
+
+ # Enforce editorship before validating against the dataset's columns,
+ # so a caller without edit rights learns nothing beyond "forbidden".
+ # UpdateDatasetCommand and RefreshDatasetCommand re-check this.
+ try:
+ security_manager.raise_for_editorship(dataset)
+ except SupersetSecurityException:
+ await ctx.warning("Dataset update forbidden: dataset_id=%s" % (dataset_id,))
+ return UpdateDatasetResponse(
+ dataset_id=dataset_id,
+ permission_denied=True,
+ error="You must be an owner of this dataset (or an Admin) to "
+ "update it. Ask the user to update it or grant access; do not "
+ "retry.",
+ )
+
+ if "sql" in updates and not dataset.sql:
+ return UpdateDatasetResponse(
+ dataset_id=dataset_id,
+ error="sql can only be set on a virtual dataset; this dataset "
+ "is a physical table.",
+ )
+
+ sync_columns = (
+ request.sync_columns
+ if request.sync_columns is not None
+ else "sql" in updates and updates["sql"] != dataset.sql
+ )
+
+ # A new default datetime column is checked against the columns the
+ # dataset will have once the update is done: the current ones, or the
+ # re-synced ones, in which case it is applied after the sync.
+ pending_dttm_col = None
+ if updates.get("main_dttm_col") is not None:
+ if sync_columns:
+ pending_dttm_col = updates.pop("main_dttm_col")
+ elif updates["main_dttm_col"] not in _column_names(dataset):
+ dttm_col = updates["main_dttm_col"]
+ return UpdateDatasetResponse(
+ dataset_id=dataset_id,
+ error=f"main_dttm_col '{dttm_col}' is not a column of this "
+ "dataset. Use get_dataset_info to list its columns.",
+ )
+
+ columns_before = _column_names(dataset)
+ updated_properties = sorted(updates)
+
+ if updates:
+ # Same pair of commands as PUT /api/v1/dataset/<pk>?override_columns=
+ # — the update commits before the column refresh runs.
+ with event_logger.log_context(action="mcp.update_dataset.update"):
+ dataset = UpdateDatasetCommand(
+ dataset_id, updates, override_columns=sync_columns
+ ).run()
+
+ warnings: list[str] = []
+ added_columns: list[str] = []
+ removed_columns: list[str] = []
+ columns_synced = False
+ if sync_columns:
+ try:
+ with event_logger.log_context(action="mcp.update_dataset.sync_columns"):
+ dataset = RefreshDatasetCommand(dataset_id).run()
+ columns_after = _column_names(dataset)
+ added_columns = sorted(columns_after - columns_before)
+ removed_columns = sorted(columns_before - columns_after)
+ columns_synced = True
Review Comment:
Fixed in a7739e4aa9. `RefreshDatasetCommand` now sets `metadata_refreshed`, which stays false when it skips the refresh, and `update_dataset` reports a skipped refresh as a warning with `columns_synced=false`. A pending `main_dttm_col` is then left unchanged, with its own warning. Covered by `test_update_dataset_skipped_refresh_is_a_warning`, plus `test_refresh_reports_completed_refresh` for the command. I added the flag to the command rather than matching its log message.
##########
superset/mcp_service/dataset/tool/update_dataset.py:
##########
@@ -0,0 +1,289 @@
+# 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.
+
+"""
+MCP tool: update_dataset
+"""
+
+import logging
+from typing import Any
+
+from fastmcp import Context
+from sqlalchemy.exc import SQLAlchemyError
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset import security_manager
+from superset.exceptions import SupersetException
+from superset.extensions import event_logger
+from superset.mcp_service.dataset.schemas import (
+ UpdateDatasetRequest,
+ UpdateDatasetResponse,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _column_names(dataset: Any) -> set[str]:
+ return {column.column_name for column in dataset.columns}
+
+
+def _sync_error_message(ex: Exception) -> str:
+ # Raw SQLAlchemy text can leak SQL or connection details; Superset
+ # exception messages are user-facing by design.
+ if isinstance(ex, SQLAlchemyError):
+ return "a database error occurred"
+ return str(ex)
+
+
+@tool(
+ tags=["mutate"],
+ class_permission_name="Dataset",
+ method_permission_name="write",
+ annotations=ToolAnnotations(
+ title="Update dataset",
+ readOnlyHint=False,
+ # Rewriting a virtual dataset's SQL or re-syncing its columns changes
+ # what every chart built on it queries — non-additive, like
+ # update_chart.
+ destructiveHint=True,
+ idempotentHint=False,
+ openWorldHint=False,
+ ),
+)
+async def update_dataset( # noqa: C901
+ request: UpdateDatasetRequest, ctx: Context
+) -> UpdateDatasetResponse:
+ """Update a dataset's name, SQL, description, default datetime column or
+ cache timeout, and optionally re-sync its columns from the data source.
+
+ Only the properties you pass are changed. ``sql`` applies to virtual
+ datasets only. When ``sql`` changes, columns are re-synced from the new
+ query (like "Sync columns from source" in the dataset editor) unless
+ ``sync_columns`` is false; pass ``sync_columns=true`` on its own to pick
+ up schema changes in the underlying table or query. Calculated columns
+ and saved metrics are kept. Use update_dataset_metric to edit metrics.
+ Requires ownership of the dataset (or Admin).
+
+ Check ``removed_columns`` in the response: charts that use those columns
+ fail until they are updated. ``warnings`` reports problems that did not
+ undo the update, e.g. a column re-sync that failed after the SQL was saved.
+
+ Workflow:
+ 1. Call get_dataset_info to inspect the dataset
+ 2. Call this tool with the dataset ID and only the properties to change
+
+ Example usage:
+ ```json
+ {
+ "dataset_id": 123,
+ "sql": "SELECT region, SUM(revenue) AS revenue FROM sales GROUP BY region",
+ "description": "Revenue by region"
+ }
+ ```
+ """
+ updates = request.updates()
+ await ctx.info(
+ "Updating dataset: dataset_id=%s, properties=%s, sync_columns=%s"
+ % (request.dataset_id, sorted(updates), request.sync_columns)
+ )
+
+ try:
+ from sqlalchemy.orm import joinedload, subqueryload
+
+ from superset.commands.dataset.exceptions import (
+ DatasetForbiddenError,
+ DatasetInvalidError,
+ DatasetNotFoundError,
+ DatasetUpdateFailedError,
+ )
+ from superset.commands.dataset.refresh import RefreshDatasetCommand
+ from superset.commands.dataset.update import UpdateDatasetCommand
+ from superset.connectors.sqla.models import SqlaTable
+ from superset.exceptions import SupersetSecurityException
+ from superset.mcp_service.dataset.dataset_utils import resolve_dataset
+ from superset.mcp_service.utils.url_utils import get_superset_base_url
+
+ eager_options = [
+ subqueryload(SqlaTable.columns),
+ joinedload(SqlaTable.database),
+ ]
+
+ with event_logger.log_context(action="mcp.update_dataset.lookup"):
+ dataset = resolve_dataset(request.dataset_id, eager_options)
+
+ if dataset is None:
+ display_id = str(request.dataset_id)[:200]
+ await ctx.warning("Dataset not found: %s" % (display_id,))
+ return UpdateDatasetResponse(
+ error=(
+ f"No dataset found with identifier: {display_id}."
+ " Use list_datasets to get valid dataset IDs."
+ ),
+ )
+
+ dataset_id = dataset.id
+
+ # Enforce editorship before validating against the dataset's columns,
+ # so a caller without edit rights learns nothing beyond "forbidden".
+ # UpdateDatasetCommand and RefreshDatasetCommand re-check this.
+ try:
+ security_manager.raise_for_editorship(dataset)
+ except SupersetSecurityException:
+ await ctx.warning("Dataset update forbidden: dataset_id=%s" % (dataset_id,))
+ return UpdateDatasetResponse(
+ dataset_id=dataset_id,
+ permission_denied=True,
+ error="You must be an owner of this dataset (or an Admin) to "
+ "update it. Ask the user to update it or grant access; do not "
+ "retry.",
+ )
+
+ if "sql" in updates and not dataset.sql:
+ return UpdateDatasetResponse(
+ dataset_id=dataset_id,
+ error="sql can only be set on a virtual dataset; this dataset "
+ "is a physical table.",
+ )
+
+ sync_columns = (
+ request.sync_columns
+ if request.sync_columns is not None
+ else "sql" in updates and updates["sql"] != dataset.sql
+ )
+
+ # A new default datetime column is checked against the columns the
+ # dataset will have once the update is done: the current ones, or the
+ # re-synced ones, in which case it is applied after the sync.
+ pending_dttm_col = None
+ if updates.get("main_dttm_col") is not None:
+ if sync_columns:
+ pending_dttm_col = updates.pop("main_dttm_col")
+ elif updates["main_dttm_col"] not in _column_names(dataset):
+ dttm_col = updates["main_dttm_col"]
+ return UpdateDatasetResponse(
+ dataset_id=dataset_id,
+ error=f"main_dttm_col '{dttm_col}' is not a column of this "
+ "dataset. Use get_dataset_info to list its columns.",
+ )
+
+ columns_before = _column_names(dataset)
+ updated_properties = sorted(updates)
+
+ if updates:
+ # Same pair of commands as PUT /api/v1/dataset/<pk>?override_columns=
+ # — the update commits before the column refresh runs.
+ with event_logger.log_context(action="mcp.update_dataset.update"):
+ dataset = UpdateDatasetCommand(
+ dataset_id, updates, override_columns=sync_columns
+ ).run()
+
+ warnings: list[str] = []
+ added_columns: list[str] = []
+ removed_columns: list[str] = []
+ columns_synced = False
+ if sync_columns:
+ try:
+ with event_logger.log_context(action="mcp.update_dataset.sync_columns"):
+ dataset = RefreshDatasetCommand(dataset_id).run()
+ columns_after = _column_names(dataset)
+ added_columns = sorted(columns_after - columns_before)
+ removed_columns = sorted(columns_before - columns_after)
+ columns_synced = True
+ except (SupersetException, SQLAlchemyError) as ex:
+ await ctx.warning(
+ "Dataset column sync failed: %s: %s" % (type(ex).__name__, ex)
+ )
+ warnings.append(
+ "The update was saved, but re-syncing columns failed "
+ f"({_sync_error_message(ex)}). The column list may not "
+ "match the dataset's SQL; retry with sync_columns=true."
+ )
+
+ if pending_dttm_col is not None:
+ if not columns_synced:
+ warnings.append(
+ f"main_dttm_col was not changed to '{pending_dttm_col}' "
+ "because the columns could not be re-synced."
+ )
+ elif pending_dttm_col not in _column_names(dataset):
+ warnings.append(
+ f"main_dttm_col was not changed: '{pending_dttm_col}' is not "
+ "a column of the dataset after the update."
Review Comment:
The name can only be checked once the new SQL is saved: the new columns come from running that SQL, which the refresh does after `UpdateDatasetCommand` has committed. Rejecting the call at that point would mean reverting a committed update, so the tool keeps the saved SQL and says in `warnings` that `main_dttm_col` was not changed. a7739e4aa9 documents this in the tool docstring. I also corrected the testing instructions in the description, which described a rejection without saying it only applies when columns are not re-synced.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "tien238lnd (via GitHub)" <gi...@apache.org>.
tien238lnd commented on PR #44337:
URL: https://github.com/apache/superset/pull/44337#issuecomment-5711541691
Addressed the review in a7739e4aa9, and rebased onto master.
- `restore_dataset` checks editorship before any response that names the dataset (CodeAnt, Copilot, Bito).
- `update_dataset` reports a refresh that `RefreshDatasetCommand` skips as a warning. This needed a `metadata_refreshed` flag on the command, the only change outside `superset/mcp_service/`. A failure while setting `main_dttm_col` after the re-sync is now a partial success, and `cache_timeout` rejects booleans.
- `delete_dataset` runs the command's `validate()` (lookup and editorship) before counting dependent charts and dashboards (Bito).
- The three tools are registered in the tool metadata test from #43828 and in `test_rbac_tool_enforcement.py` (Copilot).
Two suggestions I left as they are:
- Bito: `test_list_datasets_deleted_state.py` should expect `ValidationError` instead of `ToolError`. The server-side error type does not reach the test: `Client.call_tool` raises `ToolError` for any error result, and the test passes.
- Copilot (suppressed comment): add `delete_dataset` and `restore_dataset` to the write-tools list in the server instructions. That list does not name `delete_chart`, `restore_chart`, `delete_dashboard` or `restore_dashboard` either; their descriptions say they need editor rights, and the dataset tools follow the same pattern. Listing all delete/restore tools there would fit better in a separate change.
The commits on this branch also now carry my personal GitHub identity instead of a work address; the code is unchanged apart from the commit above.
`pytest tests/unit_tests/mcp_service tests/unit_tests/commands/dataset` on this head: 4444 passed, 1 skipped, and 2 failures in `test_query_dataset.py` that fail the same way on master (4c77b0b924) in my environment.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "codeant-ai-for-open-source[bot] (via GitHub)" <gi...@apache.org>.
codeant-ai-for-open-source[bot] commented on code in PR #44337:
URL: https://github.com/apache/superset/pull/44337#discussion_r4023724422
##########
superset/mcp_service/dataset/schemas.py:
##########
@@ -718,6 +739,237 @@ class UpdateDatasetMetricResponse(BaseModel):
)
+class DeleteDatasetRequest(BaseModel):
+ """Request schema for delete_dataset."""
+
+ identifier: int | str = Field(
+ ...,
+ description=(
+ "Dataset identifier - numeric ID or UUID string (NOT the table name)."
+ ),
+ )
+
+ @field_validator("identifier", mode="before")
+ @classmethod
+ def reject_bool_identifier(cls, value: object) -> object:
+ """bool is a subclass of int, so identifier=true would coerce to
+ dataset ID 1 and delete the wrong object; reject it outright."""
+ if isinstance(value, bool):
+ raise ValueError("identifier must be an integer ID or UUID string")
+ return value
+
+
+class DeleteDatasetResponse(BaseModel):
+ """Result of a delete_dataset operation."""
+
+ success: bool = Field(description="Whether the dataset was deleted")
+ deleted_id: int | None = Field(None, description="ID of the deleted dataset")
+ deleted_name: str | None = Field(
+ None, description="Table name of the deleted dataset"
+ )
+ soft_deleted: bool = Field(
+ False,
+ description=(
+ "True when the dataset was soft-deleted (moved to trash, because the "
+ "SOFT_DELETE feature flag is enabled) and can be restored by an "
+ "owner or Admin. False means the delete was permanent."
+ ),
+ )
+ affected_chart_count: int = Field(
+ 0,
+ description=(
+ "Number of charts (visible to the caller) built on this dataset. "
+ "They stop working while the dataset is deleted."
+ ),
+ )
+ affected_dashboard_count: int = Field(
+ 0,
+ description=(
+ "Number of dashboards (visible to the caller) containing those charts."
+ ),
+ )
+ message: str | None = Field(None, description="Human-readable outcome message")
+ error: str | None = Field(None, description="Error message if the delete failed")
+ error_type: str | None = Field(None, description="Type of error if failed")
+ permission_denied: bool = Field(
+ False,
+ description=(
+ "True when the caller lacks permission to delete the dataset (do not "
+ "retry; ask the user)."
+ ),
+ )
+
+
+class RestoreDatasetRequest(BaseModel):
+ """Request schema for restore_dataset."""
+
+ identifier: int | str = Field(
+ ...,
+ description=(
+ "Dataset identifier - numeric ID or UUID string (NOT the table name)."
+ ),
+ )
+
+ @field_validator("identifier", mode="before")
+ @classmethod
+ def reject_bool_identifier(cls, value: object) -> object:
+ """bool is a subclass of int, so identifier=true would coerce to
+ dataset ID 1 and target the wrong object; reject it outright."""
+ if isinstance(value, bool):
+ raise ValueError("identifier must be an integer ID or UUID string")
+ return value
+
+
+class RestoreDatasetResponse(BaseModel):
+ """Result of a restore_dataset operation."""
+
+ success: bool = Field(description="Whether the dataset was restored from trash")
+ restored_id: int | None = Field(None, description="ID of the restored dataset")
+ restored_name: str | None = Field(
+ None, description="Table name of the restored dataset"
+ )
+ message: str | None = Field(None, description="Human-readable outcome message")
+ error: str | None = Field(None, description="Error message if the restore failed")
+ error_type: str | None = Field(None, description="Type of error if failed")
+ permission_denied: bool = Field(
+ False,
+ description=(
+ "True when the caller lacks permission to restore the dataset (do not "
+ "retry; ask the user)."
+ ),
+ )
+
+
+UPDATABLE_DATASET_FIELDS: frozenset[str] = frozenset(
+ {
+ "table_name",
+ "sql",
+ "description",
+ "main_dttm_col",
+ "cache_timeout",
+ }
+)
+
+
+class UpdateDatasetRequest(BaseModel):
+ """Request schema for update_dataset."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ dataset_id: int | str = Field(
+ ...,
+ description="Dataset identifier — numeric ID or UUID string. "
+ "Use list_datasets to find valid IDs.",
+ )
+ table_name: str | None = Field(
+ None,
+ max_length=250,
+ description="New dataset name. For a virtual dataset this is just its "
+ "label; for a physical dataset it must match an existing table.",
+ )
+ sql: str | None = Field(
+ None,
+ description="New SQL for a virtual dataset. Rejected for physical "
+ "datasets. Columns are re-synced from the new query unless "
+ "sync_columns is false.",
+ )
+ description: str | None = Field(None, description="Dataset description.")
+ main_dttm_col: str | None = Field(
+ None,
+ description="Default datetime column; must be one of the dataset's "
+ "columns (after re-sync, when columns are re-synced).",
+ )
+ cache_timeout: int | None = Field(
+ None,
+ ge=-1,
+ description="Cache timeout in seconds. 0 means the cache never expires, "
+ "-1 bypasses the cache, null falls back to the database default.",
+ )
Review Comment:
**Suggestion:** `cache_timeout=true` is coerced to integer `1`, so invalid boolean input silently changes the dataset's cache timeout. [type error]
**Assessment:** 🟠 `Major` · 🔁 `Occurrence: Sometimes`
[](https://docs.codeant.ai/cli/resolve-pr-comments-skill) [](https://app.codeant.ai/fix-in-ide?tool=cursor&prompt_id=b507ddf720c84e9db895de7307d0b757&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset) [](https://app.codeant.ai/fix-in-ide?tool=vscode-claude&prompt_id=b507ddf720c84e9db895de7307d0b757&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:** superset/mcp_service/dataset/schemas.py
**Line:** 882:887
**Comment:**
*Type Error: `cache_timeout=true` is coerced to integer `1`, so invalid boolean input silently changes the dataset's cache timeout.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
```
</details>
<a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F44337&comment_hash=bb56012cf01a3643832740000e562ed7f57d748d7bd861be0ae7944dad044ab0&reaction=like'>👍</a> | <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F44337&comment_hash=bb56012cf01a3643832740000e562ed7f57d748d7bd861be0ae7944dad044ab0&reaction=dislike'>👎</a>
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on code in PR #44337:
URL: https://github.com/apache/superset/pull/44337#discussion_r4101775821
##########
tests/unit_tests/mcp_service/dataset/tool/test_restore_dataset.py:
##########
@@ -0,0 +1,377 @@
+# 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 the restore_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from datetime import datetime
+from typing import Any
+from unittest.mock import Mock, patch
+from uuid import UUID
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_FIND = "superset.daos.dataset.DatasetDAO.find_by_id_or_uuid"
+_COMMAND = "superset.commands.dataset.restore.RestoreDatasetCommand"
+
+_UUID = UUID("11111111-2222-3333-4444-555555555555")
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool's editorship gate calls the real security manager; default
+ # it to a no-op (caller is an editor) so unrelated tests keep passing.
+ # The disclosure tests below re-patch it to raise.
+ with patch("superset.security_manager.raise_for_editorship"):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(
+ dataset_id: int = 10,
+ table_name: str = "orders",
+ deleted: bool = True,
+) -> Mock:
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ dataset.uuid = _UUID
+ dataset.deleted_at = datetime(2026, 7, 1) if deleted else None
+ return dataset
+
+
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_not_found(mock_find: Mock, mcp_server: object) -> None:
+ mock_find.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_not_in_trash(
+ mock_find: Mock, mcp_server: object
+) -> None:
+ mock_find.return_value = _mock_dataset(deleted=False)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotDeleted"
+ assert "not in trash" in (content["error"] or "").lower()
+
+
+@patch(_COMMAND)
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_success_by_numeric_id(
+ mock_find: Mock, mock_command: Mock, mcp_server: object
+) -> None:
+ mock_find.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["restored_id"] == 10
+ assert "orders" in content["restored_name"]
+ assert content["permission_denied"] is False
+ mock_command.assert_called_once_with(str(_UUID))
+ mock_command.return_value.run.assert_called_once()
+
+
+@patch(_COMMAND)
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_success_by_uuid(
+ mock_find: Mock, mock_command: Mock, mcp_server: object
+) -> None:
+ mock_find.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "restore_dataset", {"request": {"identifier": str(_UUID)}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["restored_id"] == 10
+ mock_find.assert_called_once_with(
+ str(_UUID), skip_base_filter=True, skip_visibility_filter=True
+ )
+ mock_command.assert_called_once_with(str(_UUID))
+
+
+@patch(_COMMAND)
+@patch(_FIND)
+@pytest.mark.asyncio
+async def test_restore_dataset_permission_denied(
+ mock_find: Mock, mock_command: Mock, mcp_server: object
+) -> None:
+ from superset.commands.dataset.exceptions import DatasetForbiddenError
Review Comment:
<!-- Bito Reply -->
The suggestion to move the import of `DatasetForbiddenError` to the module-level import block is correct and follows the project's coding standards. Applying this change improves consistency with the existing imports in the file.
**tests/unit_tests/mcp_service/dataset/tool/test_restore_dataset.py**
```
from superset.commands.dataset.exceptions import DatasetForbiddenError
```
##########
superset/mcp_service/dataset/schemas.py:
##########
@@ -718,6 +739,246 @@ class UpdateDatasetMetricResponse(BaseModel):
)
+class DeleteDatasetRequest(BaseModel):
+ """Request schema for delete_dataset."""
+
+ identifier: int | str = Field(
+ ...,
+ description=(
+ "Dataset identifier - numeric ID or UUID string (NOT the table name)."
+ ),
+ )
+
+ @field_validator("identifier", mode="before")
+ @classmethod
+ def reject_bool_identifier(cls, value: object) -> object:
+ """bool is a subclass of int, so identifier=true would coerce to
+ dataset ID 1 and delete the wrong object; reject it outright."""
+ if isinstance(value, bool):
+ raise ValueError("identifier must be an integer ID or UUID string")
+ return value
+
+
+class DeleteDatasetResponse(BaseModel):
+ """Result of a delete_dataset operation."""
+
+ success: bool = Field(description="Whether the dataset was deleted")
+ deleted_id: int | None = Field(None, description="ID of the deleted dataset")
+ deleted_name: str | None = Field(
+ None, description="Table name of the deleted dataset"
+ )
+ soft_deleted: bool = Field(
+ False,
+ description=(
+ "True when the dataset was soft-deleted (moved to trash, because the "
+ "SOFT_DELETE feature flag is enabled) and can be restored by an "
+ "owner or Admin. False means the delete was permanent."
+ ),
+ )
+ affected_chart_count: int = Field(
+ 0,
+ description=(
+ "Number of charts (visible to the caller) built on this dataset. "
+ "They stop working while the dataset is deleted."
+ ),
+ )
+ affected_dashboard_count: int = Field(
+ 0,
+ description=(
+ "Number of dashboards (visible to the caller) containing those charts."
+ ),
+ )
+ message: str | None = Field(None, description="Human-readable outcome message")
+ error: str | None = Field(None, description="Error message if the delete failed")
+ error_type: str | None = Field(None, description="Type of error if failed")
+ permission_denied: bool = Field(
+ False,
+ description=(
+ "True when the caller lacks permission to delete the dataset (do not "
+ "retry; ask the user)."
+ ),
+ )
+
+
+class RestoreDatasetRequest(BaseModel):
+ """Request schema for restore_dataset."""
+
+ identifier: int | str = Field(
+ ...,
+ description=(
+ "Dataset identifier - numeric ID or UUID string (NOT the table name)."
+ ),
+ )
+
+ @field_validator("identifier", mode="before")
+ @classmethod
+ def reject_bool_identifier(cls, value: object) -> object:
+ """bool is a subclass of int, so identifier=true would coerce to
+ dataset ID 1 and target the wrong object; reject it outright."""
+ if isinstance(value, bool):
+ raise ValueError("identifier must be an integer ID or UUID string")
+ return value
+
+
+class RestoreDatasetResponse(BaseModel):
+ """Result of a restore_dataset operation."""
+
+ success: bool = Field(description="Whether the dataset was restored from trash")
+ restored_id: int | None = Field(None, description="ID of the restored dataset")
+ restored_name: str | None = Field(
+ None, description="Table name of the restored dataset"
+ )
+ message: str | None = Field(None, description="Human-readable outcome message")
+ error: str | None = Field(None, description="Error message if the restore failed")
+ error_type: str | None = Field(None, description="Type of error if failed")
+ permission_denied: bool = Field(
+ False,
+ description=(
+ "True when the caller lacks permission to restore the dataset (do not "
+ "retry; ask the user)."
+ ),
+ )
+
+
+UPDATABLE_DATASET_FIELDS: frozenset[str] = frozenset(
+ {
+ "table_name",
+ "sql",
+ "description",
+ "main_dttm_col",
+ "cache_timeout",
+ }
+)
+
+
+class UpdateDatasetRequest(BaseModel):
+ """Request schema for update_dataset."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ dataset_id: int | str = Field(
Review Comment:
<!-- Bito Reply -->
The decision to keep `dataset_id` for consistency with existing operations while adding `identifier` as an alias is appropriate. This approach maintains backward compatibility for callers while providing the requested flexibility, avoiding a breaking rename.
##########
tests/unit_tests/mcp_service/test_rbac_tool_enforcement.py:
##########
@@ -110,6 +110,24 @@
"write",
"Dataset",
),
+ (
+ "update_dataset",
+ {"dataset_id": 1, "description": "denied"},
+ "write",
+ "Dataset",
+ ),
+ (
+ "delete_dataset",
+ {"identifier": 1},
+ "write",
+ "Dataset",
+ ),
+ (
+ "restore_dataset",
+ {"identifier": 1},
+ "write",
+ "Dataset",
+ ),
Review Comment:
<!-- Bito Reply -->
The reviewer's suggestion to change the test expectation is incorrect because the tools in question explicitly declare `tags=["mutate"]`. As you noted, the authentication hook correctly assigns the "write" permission based on these tags before the fallback to "read" can occur. Therefore, the current test expectation is already correct and should not be changed.
##########
tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py:
##########
@@ -0,0 +1,336 @@
+# 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 the delete_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_RESOLVE = "superset.mcp_service.dataset.tool.delete_dataset.resolve_dataset"
+_COUNT = "superset.mcp_service.dataset.tool.delete_dataset._count_affected_objects"
+_RUN = "superset.commands.dataset.delete.DeleteDatasetCommand.run"
+_VALIDATE = "superset.commands.dataset.delete.DeleteDatasetCommand.validate"
+_FLAG = "superset.mcp_service.dataset.tool.delete_dataset.is_feature_enabled"
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ """Provide the FastMCP app instance under test."""
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ """Authenticate every tool call as a mock admin user."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool validates the command (lookup + editorship) before counting
+ # dependents; default it to a pass so tests that patch run() alone keep
+ # working. The permission test re-patches it to raise.
+ with patch(_VALIDATE):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(dataset_id: int = 10, table_name: str = "orders") -> Mock:
+ """Build a minimal dataset stand-in with the attributes the tool reads."""
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ return dataset
+
+
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_not_found(mock_resolve: Mock, mcp_server: object) -> None:
+ mock_resolve.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_success(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["deleted_id"] == 10
+ assert "orders" in content["deleted_name"]
+ assert content["permission_denied"] is False
+ assert content["affected_chart_count"] == 0
+ mock_run.assert_called_once()
+ mock_count.assert_called_once_with(10)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_by_uuid(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ uuid = "11111111-2222-3333-4444-555555555555"
+ mock_resolve.return_value = _mock_dataset(dataset_id=10)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": uuid}}
+ )
+
+ assert result.structured_content["success"] is True
+ mock_resolve.assert_called_once_with(uuid)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_FLAG)
Review Comment:
<!-- Bito Reply -->
The reviewer's suggestion to replace the lambda with a named helper is a valid request to improve code readability and maintainability, aligning with common clean code practices. While a lambda is concise, a named helper function provides better clarity and type safety, especially in test suites where mock behavior is defined. You can implement this by defining a small, typed helper function at the module level and assigning it to the `side_effect` attribute.
**tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py**
```
def _is_soft_delete_enabled(flag: str) -> bool:
return flag == "SOFT_DELETE"
# In the test function:
mock_flag.side_effect = _is_soft_delete_enabled
```
##########
superset/mcp_service/dataset/tool/update_dataset.py:
##########
@@ -0,0 +1,320 @@
+# 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.
+
+"""
+MCP tool: update_dataset
+"""
+
+import logging
+from typing import Any
+
+from fastmcp import Context
+from sqlalchemy.exc import SQLAlchemyError
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset import security_manager
+from superset.exceptions import SupersetException
+from superset.extensions import event_logger
+from superset.mcp_service.dataset.schemas import (
+ UpdateDatasetRequest,
+ UpdateDatasetResponse,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _column_names(dataset: Any) -> set[str]:
+ return {column.column_name for column in dataset.columns}
+
+
+def _sync_error_message(ex: Exception) -> str:
+ # Raw SQLAlchemy text can leak SQL or connection details; Superset
+ # exception messages are user-facing by design.
+ if isinstance(ex, SQLAlchemyError):
+ return "a database error occurred"
+ return str(ex)
+
+
+@tool(
+ tags=["mutate"],
+ class_permission_name="Dataset",
+ method_permission_name="write",
+ annotations=ToolAnnotations(
+ title="Update dataset",
+ readOnlyHint=False,
+ # Rewriting a virtual dataset's SQL or re-syncing its columns changes
+ # what every chart built on it queries — non-additive, like
+ # update_chart.
+ destructiveHint=True,
+ idempotentHint=False,
+ openWorldHint=False,
+ ),
+)
+async def update_dataset( # noqa: C901
Review Comment:
<!-- Bito Reply -->
The reviewer's suggestion to refactor the `update_dataset` function is noted. However, the current implementation follows a linear, sequential update process—resolve, gate, validate, update, re-sync, apply deferred columns, and report—where each step depends on the output of the previous one. Splitting this into multiple helper functions would introduce unnecessary complexity by requiring the passing of state objects (`dataset`, `updates`, `warnings`, `columns_synced`) between them, without providing a clear benefit for testability or maintainability, as the branches are already covered by the tool's current structure.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "tien238lnd (via GitHub)" <gi...@apache.org>.
tien238lnd commented on code in PR #44337:
URL: https://github.com/apache/superset/pull/44337#discussion_r4101781368
##########
tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py:
##########
@@ -0,0 +1,336 @@
+# 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 the delete_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_RESOLVE = "superset.mcp_service.dataset.tool.delete_dataset.resolve_dataset"
+_COUNT = "superset.mcp_service.dataset.tool.delete_dataset._count_affected_objects"
+_RUN = "superset.commands.dataset.delete.DeleteDatasetCommand.run"
+_VALIDATE = "superset.commands.dataset.delete.DeleteDatasetCommand.validate"
+_FLAG = "superset.mcp_service.dataset.tool.delete_dataset.is_feature_enabled"
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ """Provide the FastMCP app instance under test."""
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ """Authenticate every tool call as a mock admin user."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool validates the command (lookup + editorship) before counting
+ # dependents; default it to a pass so tests that patch run() alone keep
+ # working. The permission test re-patches it to raise.
+ with patch(_VALIDATE):
+ mock_user = Mock()
Review Comment:
Leaving these. The repo asks for type hints on new code's signatures, which the fixtures have; `mock_user: Mock = Mock()` restates the right-hand side and mypy reads it the same either way.
##########
tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py:
##########
@@ -0,0 +1,336 @@
+# 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 the delete_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_RESOLVE = "superset.mcp_service.dataset.tool.delete_dataset.resolve_dataset"
+_COUNT = "superset.mcp_service.dataset.tool.delete_dataset._count_affected_objects"
+_RUN = "superset.commands.dataset.delete.DeleteDatasetCommand.run"
+_VALIDATE = "superset.commands.dataset.delete.DeleteDatasetCommand.validate"
+_FLAG = "superset.mcp_service.dataset.tool.delete_dataset.is_feature_enabled"
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ """Provide the FastMCP app instance under test."""
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ """Authenticate every tool call as a mock admin user."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool validates the command (lookup + editorship) before counting
+ # dependents; default it to a pass so tests that patch run() alone keep
+ # working. The permission test re-patches it to raise.
+ with patch(_VALIDATE):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(dataset_id: int = 10, table_name: str = "orders") -> Mock:
+ """Build a minimal dataset stand-in with the attributes the tool reads."""
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ return dataset
+
+
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_not_found(mock_resolve: Mock, mcp_server: object) -> None:
+ mock_resolve.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_success(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["deleted_id"] == 10
+ assert "orders" in content["deleted_name"]
+ assert content["permission_denied"] is False
+ assert content["affected_chart_count"] == 0
+ mock_run.assert_called_once()
+ mock_count.assert_called_once_with(10)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_by_uuid(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ uuid = "11111111-2222-3333-4444-555555555555"
+ mock_resolve.return_value = _mock_dataset(dataset_id=10)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": uuid}}
+ )
+
+ assert result.structured_content["success"] is True
+ mock_resolve.assert_called_once_with(uuid)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_FLAG)
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_soft_delete_reports_restorable(
+ mock_resolve: Mock,
+ mock_run: Mock,
+ mock_flag: Mock,
+ mock_count: Mock,
+ mcp_server: object,
+) -> None:
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+ mock_flag.side_effect = lambda flag: flag == "SOFT_DELETE"
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["soft_deleted"] is True
+ assert "restor" in (content["message"] or "").lower()
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_FLAG)
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_hard_delete_reports_permanent(
+ mock_resolve: Mock,
+ mock_run: Mock,
+ mock_flag: Mock,
+ mock_count: Mock,
+ mcp_server: object,
+) -> None:
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+ mock_flag.return_value = False
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["soft_deleted"] is False
+ assert "permanent" in (content["message"] or "").lower()
+
+
+@patch(_COUNT, return_value=(3, 2))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_reports_affected_charts(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["affected_chart_count"] == 3
+ assert content["affected_dashboard_count"] == 2
+ assert "3 chart(s)" in (content["message"] or "")
+
+
+def test_count_affected_objects_only_counts_accessible() -> None:
+ """Counts must not disclose charts/dashboards the caller cannot access."""
+ from superset.mcp_service.dataset.tool.delete_dataset import (
+ _count_affected_objects,
+ )
+
+ visible_chart, hidden_chart = Mock(), Mock()
+ visible_dashboard, hidden_dashboard = Mock(), Mock()
+ security_manager = Mock()
Review Comment:
Same as the fixture locals above: `visible_chart: Mock = Mock()` adds nothing mypy does not already infer, so I left the tuple assignment as it reads.
##########
superset/mcp_service/dataset/tool/update_dataset.py:
##########
@@ -0,0 +1,289 @@
+# 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.
+
+"""
+MCP tool: update_dataset
+"""
+
+import logging
+from typing import Any
+
+from fastmcp import Context
+from sqlalchemy.exc import SQLAlchemyError
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset import security_manager
+from superset.exceptions import SupersetException
+from superset.extensions import event_logger
+from superset.mcp_service.dataset.schemas import (
+ UpdateDatasetRequest,
+ UpdateDatasetResponse,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _column_names(dataset: Any) -> set[str]:
+ return {column.column_name for column in dataset.columns}
+
+
+def _sync_error_message(ex: Exception) -> str:
+ # Raw SQLAlchemy text can leak SQL or connection details; Superset
+ # exception messages are user-facing by design.
+ if isinstance(ex, SQLAlchemyError):
+ return "a database error occurred"
+ return str(ex)
+
+
+@tool(
+ tags=["mutate"],
+ class_permission_name="Dataset",
+ method_permission_name="write",
+ annotations=ToolAnnotations(
+ title="Update dataset",
+ readOnlyHint=False,
+ # Rewriting a virtual dataset's SQL or re-syncing its columns changes
+ # what every chart built on it queries — non-additive, like
+ # update_chart.
+ destructiveHint=True,
+ idempotentHint=False,
+ openWorldHint=False,
+ ),
+)
+async def update_dataset( # noqa: C901
+ request: UpdateDatasetRequest, ctx: Context
+) -> UpdateDatasetResponse:
+ """Update a dataset's name, SQL, description, default datetime column or
+ cache timeout, and optionally re-sync its columns from the data source.
+
+ Only the properties you pass are changed. ``sql`` applies to virtual
+ datasets only. When ``sql`` changes, columns are re-synced from the new
+ query (like "Sync columns from source" in the dataset editor) unless
+ ``sync_columns`` is false; pass ``sync_columns=true`` on its own to pick
+ up schema changes in the underlying table or query. Calculated columns
+ and saved metrics are kept. Use update_dataset_metric to edit metrics.
+ Requires ownership of the dataset (or Admin).
+
+ Check ``removed_columns`` in the response: charts that use those columns
+ fail until they are updated. ``warnings`` reports problems that did not
+ undo the update, e.g. a column re-sync that failed after the SQL was saved.
+
+ Workflow:
+ 1. Call get_dataset_info to inspect the dataset
+ 2. Call this tool with the dataset ID and only the properties to change
+
+ Example usage:
+ ```json
+ {
+ "dataset_id": 123,
+ "sql": "SELECT region, SUM(revenue) AS revenue FROM sales GROUP BY region",
+ "description": "Revenue by region"
+ }
+ ```
+ """
+ updates = request.updates()
+ await ctx.info(
+ "Updating dataset: dataset_id=%s, properties=%s, sync_columns=%s"
+ % (request.dataset_id, sorted(updates), request.sync_columns)
+ )
+
+ try:
+ from sqlalchemy.orm import joinedload, subqueryload
+
+ from superset.commands.dataset.exceptions import (
+ DatasetForbiddenError,
+ DatasetInvalidError,
+ DatasetNotFoundError,
+ DatasetUpdateFailedError,
+ )
+ from superset.commands.dataset.refresh import RefreshDatasetCommand
+ from superset.commands.dataset.update import UpdateDatasetCommand
+ from superset.connectors.sqla.models import SqlaTable
+ from superset.exceptions import SupersetSecurityException
+ from superset.mcp_service.dataset.dataset_utils import resolve_dataset
+ from superset.mcp_service.utils.url_utils import get_superset_base_url
+
+ eager_options = [
+ subqueryload(SqlaTable.columns),
+ joinedload(SqlaTable.database),
+ ]
+
+ with event_logger.log_context(action="mcp.update_dataset.lookup"):
+ dataset = resolve_dataset(request.dataset_id, eager_options)
+
+ if dataset is None:
+ display_id = str(request.dataset_id)[:200]
+ await ctx.warning("Dataset not found: %s" % (display_id,))
+ return UpdateDatasetResponse(
+ error=(
+ f"No dataset found with identifier: {display_id}."
+ " Use list_datasets to get valid dataset IDs."
+ ),
+ )
+
+ dataset_id = dataset.id
+
+ # Enforce editorship before validating against the dataset's columns,
+ # so a caller without edit rights learns nothing beyond "forbidden".
+ # UpdateDatasetCommand and RefreshDatasetCommand re-check this.
+ try:
+ security_manager.raise_for_editorship(dataset)
+ except SupersetSecurityException:
+ await ctx.warning("Dataset update forbidden: dataset_id=%s" % (dataset_id,))
+ return UpdateDatasetResponse(
+ dataset_id=dataset_id,
+ permission_denied=True,
+ error="You must be an owner of this dataset (or an Admin) to "
+ "update it. Ask the user to update it or grant access; do not "
+ "retry.",
+ )
+
+ if "sql" in updates and not dataset.sql:
+ return UpdateDatasetResponse(
+ dataset_id=dataset_id,
+ error="sql can only be set on a virtual dataset; this dataset "
+ "is a physical table.",
+ )
+
+ sync_columns = (
+ request.sync_columns
+ if request.sync_columns is not None
+ else "sql" in updates and updates["sql"] != dataset.sql
+ )
+
+ # A new default datetime column is checked against the columns the
+ # dataset will have once the update is done: the current ones, or the
+ # re-synced ones, in which case it is applied after the sync.
+ pending_dttm_col = None
+ if updates.get("main_dttm_col") is not None:
+ if sync_columns:
+ pending_dttm_col = updates.pop("main_dttm_col")
+ elif updates["main_dttm_col"] not in _column_names(dataset):
+ dttm_col = updates["main_dttm_col"]
+ return UpdateDatasetResponse(
+ dataset_id=dataset_id,
+ error=f"main_dttm_col '{dttm_col}' is not a column of this "
+ "dataset. Use get_dataset_info to list its columns.",
+ )
+
+ columns_before = _column_names(dataset)
+ updated_properties = sorted(updates)
+
+ if updates:
+ # Same pair of commands as PUT /api/v1/dataset/<pk>?override_columns=
+ # — the update commits before the column refresh runs.
+ with event_logger.log_context(action="mcp.update_dataset.update"):
+ dataset = UpdateDatasetCommand(
+ dataset_id, updates, override_columns=sync_columns
+ ).run()
+
+ warnings: list[str] = []
+ added_columns: list[str] = []
+ removed_columns: list[str] = []
+ columns_synced = False
+ if sync_columns:
+ try:
+ with event_logger.log_context(action="mcp.update_dataset.sync_columns"):
+ dataset = RefreshDatasetCommand(dataset_id).run()
+ columns_after = _column_names(dataset)
+ added_columns = sorted(columns_after - columns_before)
+ removed_columns = sorted(columns_before - columns_after)
+ columns_synced = True
+ except (SupersetException, SQLAlchemyError) as ex:
+ await ctx.warning(
+ "Dataset column sync failed: %s: %s" % (type(ex).__name__, ex)
+ )
+ warnings.append(
+ "The update was saved, but re-syncing columns failed "
+ f"({_sync_error_message(ex)}). The column list may not "
+ "match the dataset's SQL; retry with sync_columns=true."
+ )
+
+ if pending_dttm_col is not None:
+ if not columns_synced:
+ warnings.append(
+ f"main_dttm_col was not changed to '{pending_dttm_col}' "
+ "because the columns could not be re-synced."
+ )
+ elif pending_dttm_col not in _column_names(dataset):
+ warnings.append(
+ f"main_dttm_col was not changed: '{pending_dttm_col}' is not "
+ "a column of the dataset after the update."
Review Comment:
Kept as a warning on purpose. By that point the SQL update and the re-sync have committed, so returning a failure would report the opposite of what happened to the dataset. What the response does instead: `main_dttm_col` stays out of `updated_properties`, and a warning names the column and says it was not applied. The contract you quote holds for the case that can still be refused — when no re-sync is coming, an unknown `main_dttm_col` is rejected before anything is written.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on code in PR #44337:
URL: https://github.com/apache/superset/pull/44337#discussion_r4101782548
##########
tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py:
##########
@@ -0,0 +1,336 @@
+# 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 the delete_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_RESOLVE = "superset.mcp_service.dataset.tool.delete_dataset.resolve_dataset"
+_COUNT = "superset.mcp_service.dataset.tool.delete_dataset._count_affected_objects"
+_RUN = "superset.commands.dataset.delete.DeleteDatasetCommand.run"
+_VALIDATE = "superset.commands.dataset.delete.DeleteDatasetCommand.validate"
+_FLAG = "superset.mcp_service.dataset.tool.delete_dataset.is_feature_enabled"
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ """Provide the FastMCP app instance under test."""
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ """Authenticate every tool call as a mock admin user."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool validates the command (lookup + editorship) before counting
+ # dependents; default it to a pass so tests that patch run() alone keep
+ # working. The permission test re-patches it to raise.
+ with patch(_VALIDATE):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(dataset_id: int = 10, table_name: str = "orders") -> Mock:
+ """Build a minimal dataset stand-in with the attributes the tool reads."""
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ return dataset
+
+
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_not_found(mock_resolve: Mock, mcp_server: object) -> None:
+ mock_resolve.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_success(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["deleted_id"] == 10
+ assert "orders" in content["deleted_name"]
+ assert content["permission_denied"] is False
+ assert content["affected_chart_count"] == 0
+ mock_run.assert_called_once()
+ mock_count.assert_called_once_with(10)
Review Comment:
<!-- Bito Reply -->
The suggestion to add a docstring to `test_delete_dataset_success` is appropriate. Adding a docstring ensures consistency with the existing test functions in the file and explicitly documents the happy path scenario being tested.
**tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py**
```
@patch(_COUNT, return_value=(0, 0))
@patch(_RUN)
@patch(_RESOLVE)
@pytest.mark.asyncio
async def test_delete_dataset_success(
mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
) -> None:
"""Test successful dataset deletion."""
mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
```
##########
tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py:
##########
@@ -0,0 +1,336 @@
+# 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 the delete_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_RESOLVE = "superset.mcp_service.dataset.tool.delete_dataset.resolve_dataset"
+_COUNT = "superset.mcp_service.dataset.tool.delete_dataset._count_affected_objects"
+_RUN = "superset.commands.dataset.delete.DeleteDatasetCommand.run"
+_VALIDATE = "superset.commands.dataset.delete.DeleteDatasetCommand.validate"
+_FLAG = "superset.mcp_service.dataset.tool.delete_dataset.is_feature_enabled"
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ """Provide the FastMCP app instance under test."""
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ """Authenticate every tool call as a mock admin user."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool validates the command (lookup + editorship) before counting
+ # dependents; default it to a pass so tests that patch run() alone keep
+ # working. The permission test re-patches it to raise.
+ with patch(_VALIDATE):
+ mock_user = Mock()
Review Comment:
<!-- Bito Reply -->
The decision to omit the type hints is acceptable. While the repository standards encourage explicit type annotations for local variables, the redundancy of `mock_user: Mock = Mock()` is clear, and the existing fixture annotations already provide sufficient type information for static analysis tools like mypy.
##########
tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py:
##########
@@ -0,0 +1,336 @@
+# 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 the delete_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_RESOLVE = "superset.mcp_service.dataset.tool.delete_dataset.resolve_dataset"
+_COUNT = "superset.mcp_service.dataset.tool.delete_dataset._count_affected_objects"
+_RUN = "superset.commands.dataset.delete.DeleteDatasetCommand.run"
+_VALIDATE = "superset.commands.dataset.delete.DeleteDatasetCommand.validate"
+_FLAG = "superset.mcp_service.dataset.tool.delete_dataset.is_feature_enabled"
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ """Provide the FastMCP app instance under test."""
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ """Authenticate every tool call as a mock admin user."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool validates the command (lookup + editorship) before counting
+ # dependents; default it to a pass so tests that patch run() alone keep
+ # working. The permission test re-patches it to raise.
+ with patch(_VALIDATE):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(dataset_id: int = 10, table_name: str = "orders") -> Mock:
+ """Build a minimal dataset stand-in with the attributes the tool reads."""
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ return dataset
+
+
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_not_found(mock_resolve: Mock, mcp_server: object) -> None:
+ mock_resolve.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_success(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["deleted_id"] == 10
+ assert "orders" in content["deleted_name"]
+ assert content["permission_denied"] is False
+ assert content["affected_chart_count"] == 0
+ mock_run.assert_called_once()
+ mock_count.assert_called_once_with(10)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_by_uuid(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ uuid = "11111111-2222-3333-4444-555555555555"
+ mock_resolve.return_value = _mock_dataset(dataset_id=10)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": uuid}}
+ )
+
+ assert result.structured_content["success"] is True
+ mock_resolve.assert_called_once_with(uuid)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_FLAG)
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_soft_delete_reports_restorable(
+ mock_resolve: Mock,
+ mock_run: Mock,
+ mock_flag: Mock,
+ mock_count: Mock,
+ mcp_server: object,
+) -> None:
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+ mock_flag.side_effect = lambda flag: flag == "SOFT_DELETE"
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["soft_deleted"] is True
+ assert "restor" in (content["message"] or "").lower()
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_FLAG)
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_hard_delete_reports_permanent(
+ mock_resolve: Mock,
+ mock_run: Mock,
+ mock_flag: Mock,
+ mock_count: Mock,
+ mcp_server: object,
+) -> None:
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+ mock_flag.return_value = False
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["soft_deleted"] is False
+ assert "permanent" in (content["message"] or "").lower()
+
+
+@patch(_COUNT, return_value=(3, 2))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_reports_affected_charts(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["affected_chart_count"] == 3
+ assert content["affected_dashboard_count"] == 2
+ assert "3 chart(s)" in (content["message"] or "")
+
+
+def test_count_affected_objects_only_counts_accessible() -> None:
+ """Counts must not disclose charts/dashboards the caller cannot access."""
+ from superset.mcp_service.dataset.tool.delete_dataset import (
+ _count_affected_objects,
+ )
+
+ visible_chart, hidden_chart = Mock(), Mock()
+ visible_dashboard, hidden_dashboard = Mock(), Mock()
+ security_manager = Mock()
Review Comment:
<!-- Bito Reply -->
The suggestion to add `: Mock` type annotations to the local variables in `test_count_affected_objects_only_counts_accessible` is appropriate. Applying this change improves type clarity and ensures compliance with the repository's testing standards.
**tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py**
```
visible_chart: Mock = Mock()
hidden_chart: Mock = Mock()
visible_dashboard: Mock = Mock()
hidden_dashboard: Mock = Mock()
security_manager: Mock = Mock()
```
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on code in PR #44337:
URL: https://github.com/apache/superset/pull/44337#discussion_r4083141856
##########
tests/unit_tests/mcp_service/dataset/tool/test_list_datasets_deleted_state.py:
##########
@@ -0,0 +1,205 @@
+# 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_datasets ``deleted_state`` trash listing.
+
+The deleted_state param opts the list query into surfacing soft-deleted
+datasets: the session-scoped visibility bypass wraps the DAO call, the REST
+``DatasetDeletedStateFilter`` (which owns the restore-audience scoping) is
+passed through as a DAO custom filter, and ``deleted_at`` is forced into the
+loaded columns so trashed rows are distinguishable in the response.
+"""
+
+import importlib
+from collections.abc import Iterator
+from datetime import datetime
+from unittest.mock import MagicMock, Mock, patch
+from uuid import UUID
+
+import pytest
+from fastmcp import Client
+from fastmcp.exceptions import ToolError
+
+from superset.mcp_service.app import mcp
+from superset.utils import json
+
+_DAO_LIST = "superset.daos.dataset.DatasetDAO.list"
+_BYPASS = "superset.mcp_service.mcp_core.skip_visibility_filter"
+
+list_datasets_module = importlib.import_module(
+ "superset.mcp_service.dataset.tool.list_datasets"
+)
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ 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() -> Iterator[None]:
+ """Keep the tests on the metadata-allowed path; privacy gating is covered
+ in test_dataset_tools."""
+ with patch.object(
+ list_datasets_module, "user_can_view_data_model_metadata", return_value=True
+ ):
+ yield
+
+
+def _trashed_dataset_row() -> Mock:
+ row = Mock(
+ spec=[
+ "id",
+ "table_name",
+ "schema",
+ "changed_on",
+ "created_on",
+ "uuid",
+ "deleted_at",
+ ]
+ )
+ row.id = 10
+ row.table_name = "trashed_orders"
+ row.schema = "public"
+ row.changed_on = datetime(2026, 6, 1)
+ row.created_on = datetime(2026, 5, 1)
+ row.uuid = UUID("11111111-2222-3333-4444-555555555555")
+ row.deleted_at = datetime(2026, 7, 1)
+ return row
+
+
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_datasets_deleted_state_only_passes_custom_filter(
+ mock_list: Mock, mcp_server: object
+) -> None:
+ mock_list.return_value = ([], 0)
+
+ async with Client(mcp_server) as client:
+ await client.call_tool("list_datasets", {"request": {"deleted_state": "only"}})
+
+ kwargs = mock_list.call_args.kwargs
+ assert "deleted_state" in (kwargs.get("custom_filters") or {})
+ assert "deleted_at" in kwargs["columns"]
+
+
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_datasets_deleted_state_combines_with_certified(
+ mock_list: Mock, mcp_server: object
+) -> None:
+ mock_list.return_value = ([], 0)
+
+ async with Client(mcp_server) as client:
+ await client.call_tool(
+ "list_datasets",
+ {"request": {"deleted_state": "include", "certified": True}},
+ )
+
+ custom_filters = mock_list.call_args.kwargs.get("custom_filters") or {}
+ assert {"deleted_state", "certified"} <= set(custom_filters)
+
+
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_datasets_default_has_no_deleted_state_filter(
+ mock_list: Mock, mcp_server: object
+) -> None:
+ mock_list.return_value = ([], 0)
+
+ async with Client(mcp_server) as client:
+ await client.call_tool("list_datasets", {})
+
+ kwargs = mock_list.call_args.kwargs
+ assert "deleted_state" not in (kwargs.get("custom_filters") or {})
+
+
+@patch(_BYPASS)
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_datasets_deleted_state_wraps_visibility_bypass(
+ mock_list: Mock, mock_bypass: MagicMock, mcp_server: object
+) -> None:
+ from superset.connectors.sqla.models import SqlaTable
+
+ mock_list.return_value = ([], 0)
+ mock_bypass.return_value.__enter__ = Mock(return_value=None)
+ mock_bypass.return_value.__exit__ = Mock(return_value=False)
+
+ async with Client(mcp_server) as client:
+ await client.call_tool(
+ "list_datasets", {"request": {"deleted_state": "include"}}
+ )
+
+ mock_bypass.assert_called_once()
+ assert SqlaTable in mock_bypass.call_args.args
+
+
+@patch(_BYPASS)
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_datasets_no_deleted_state_no_visibility_bypass(
+ mock_list: Mock, mock_bypass: MagicMock, mcp_server: object
+) -> None:
+ mock_list.return_value = ([], 0)
+
+ async with Client(mcp_server) as client:
+ await client.call_tool("list_datasets", {})
+
+ mock_bypass.assert_not_called()
+
+
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_datasets_deleted_state_serializes_deleted_at(
+ mock_list: Mock, mcp_server: object
+) -> None:
+ mock_list.return_value = ([_trashed_dataset_row()], 1)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "list_datasets", {"request": {"deleted_state": "only"}}
+ )
+
+ data = json.loads(result.content[0].text)
+ assert data["datasets"][0]["id"] == 10
+ assert data["datasets"][0]["deleted_at"] is not None
+ assert "deleted_at" in data["columns_loaded"]
+
+
+@patch(_DAO_LIST)
+@pytest.mark.asyncio
+async def test_list_datasets_deleted_state_invalid_value_rejected(
+ mock_list: Mock, mcp_server: object
+) -> None:
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Missing test docstring</b></div>
<div id="fix">
The new test `test_list_datasets_deleted_state_invalid_value_rejected` has no docstring, unlike the module's documented helpers (`allow_data_model_metadata`, `_trashed_dataset_row` context). BITO.md rule 12148 requires every newly added test function to carry a docstring stating purpose and expected outcome. Adding one line keeps the file consistent with the mandated standard.
</div>
</div>
<small><i>Code Review Run #89cf94</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
##########
tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py:
##########
@@ -0,0 +1,344 @@
+# 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 the delete_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_RESOLVE = "superset.mcp_service.dataset.tool.delete_dataset.resolve_dataset"
+_COUNT = "superset.mcp_service.dataset.tool.delete_dataset._count_affected_objects"
+_RUN = "superset.commands.dataset.delete.DeleteDatasetCommand.run"
+_VALIDATE = "superset.commands.dataset.delete.DeleteDatasetCommand.validate"
+_FLAG = "superset.mcp_service.dataset.tool.delete_dataset.is_feature_enabled"
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ """Provide the FastMCP app instance under test."""
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ """Authenticate every tool call as a mock admin user."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool validates the command (lookup + editorship) before counting
+ # dependents; default it to a pass so tests that patch run() alone keep
+ # working. The permission test re-patches it to raise.
+ with patch(_VALIDATE):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(dataset_id: int = 10, table_name: str = "orders") -> Mock:
+ """Build a minimal dataset stand-in with the attributes the tool reads."""
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ return dataset
+
+
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_not_found(mock_resolve: Mock, mcp_server: object) -> None:
+ """An identifier that resolves to no dataset returns NotFound naming it."""
+ mock_resolve.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_success(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ """The happy path: the dataset is resolved, deleted and its dependents counted."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["deleted_id"] == 10
+ assert "orders" in content["deleted_name"]
+ assert content["permission_denied"] is False
+ assert content["affected_chart_count"] == 0
+ mock_run.assert_called_once()
+ mock_count.assert_called_once_with(10)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_by_uuid(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ """A UUID identifier is passed through to the dataset lookup unchanged."""
+ uuid = "11111111-2222-3333-4444-555555555555"
+ mock_resolve.return_value = _mock_dataset(dataset_id=10)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": uuid}}
+ )
+
+ assert result.structured_content["success"] is True
+ mock_resolve.assert_called_once_with(uuid)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_FLAG)
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_soft_delete_reports_restorable(
+ mock_resolve: Mock,
+ mock_run: Mock,
+ mock_flag: Mock,
+ mock_count: Mock,
+ mcp_server: object,
+) -> None:
+ """With SOFT_DELETE on, the response says the dataset can be restored."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+ mock_flag.side_effect = lambda flag: flag == "SOFT_DELETE"
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["soft_deleted"] is True
+ assert "restor" in (content["message"] or "").lower()
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_FLAG)
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_hard_delete_reports_permanent(
+ mock_resolve: Mock,
+ mock_run: Mock,
+ mock_flag: Mock,
+ mock_count: Mock,
+ mcp_server: object,
+) -> None:
+ """With SOFT_DELETE off, the response says the delete is permanent."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+ mock_flag.return_value = False
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["soft_deleted"] is False
+ assert "permanent" in (content["message"] or "").lower()
+
+
+@patch(_COUNT, return_value=(3, 2))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_reports_affected_charts(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ """Dependent charts and dashboards are counted in the response."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["affected_chart_count"] == 3
+ assert content["affected_dashboard_count"] == 2
+ assert "3 chart(s)" in (content["message"] or "")
+
+
+def test_count_affected_objects_only_counts_accessible() -> None:
+ """Counts must not disclose charts/dashboards the caller cannot access."""
+ from superset.mcp_service.dataset.tool.delete_dataset import (
+ _count_affected_objects,
+ )
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Inline import in test</b></div>
<div id="fix">
BITO rule 12745 requires module-level imports unless a documented circular dependency exists. `_count_affected_objects` is imported inside the test with no such justification or comment; the file already imports `superset.mcp_service.app` at top level, so moving this import adds no import-time cost.
</div>
</div>
<small><i>Code Review Run #89cf94</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
##########
tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py:
##########
@@ -0,0 +1,344 @@
+# 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 the delete_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_RESOLVE = "superset.mcp_service.dataset.tool.delete_dataset.resolve_dataset"
+_COUNT = "superset.mcp_service.dataset.tool.delete_dataset._count_affected_objects"
+_RUN = "superset.commands.dataset.delete.DeleteDatasetCommand.run"
+_VALIDATE = "superset.commands.dataset.delete.DeleteDatasetCommand.validate"
+_FLAG = "superset.mcp_service.dataset.tool.delete_dataset.is_feature_enabled"
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ """Provide the FastMCP app instance under test."""
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ """Authenticate every tool call as a mock admin user."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool validates the command (lookup + editorship) before counting
+ # dependents; default it to a pass so tests that patch run() alone keep
+ # working. The permission test re-patches it to raise.
+ with patch(_VALIDATE):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(dataset_id: int = 10, table_name: str = "orders") -> Mock:
+ """Build a minimal dataset stand-in with the attributes the tool reads."""
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ return dataset
+
+
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_not_found(mock_resolve: Mock, mcp_server: object) -> None:
+ """An identifier that resolves to no dataset returns NotFound naming it."""
+ mock_resolve.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_success(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ """The happy path: the dataset is resolved, deleted and its dependents counted."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["deleted_id"] == 10
+ assert "orders" in content["deleted_name"]
+ assert content["permission_denied"] is False
+ assert content["affected_chart_count"] == 0
+ mock_run.assert_called_once()
+ mock_count.assert_called_once_with(10)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_by_uuid(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ """A UUID identifier is passed through to the dataset lookup unchanged."""
+ uuid = "11111111-2222-3333-4444-555555555555"
+ mock_resolve.return_value = _mock_dataset(dataset_id=10)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": uuid}}
+ )
+
+ assert result.structured_content["success"] is True
+ mock_resolve.assert_called_once_with(uuid)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_FLAG)
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_soft_delete_reports_restorable(
+ mock_resolve: Mock,
+ mock_run: Mock,
+ mock_flag: Mock,
+ mock_count: Mock,
+ mcp_server: object,
+) -> None:
+ """With SOFT_DELETE on, the response says the dataset can be restored."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+ mock_flag.side_effect = lambda flag: flag == "SOFT_DELETE"
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["soft_deleted"] is True
+ assert "restor" in (content["message"] or "").lower()
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_FLAG)
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_hard_delete_reports_permanent(
+ mock_resolve: Mock,
+ mock_run: Mock,
+ mock_flag: Mock,
+ mock_count: Mock,
+ mcp_server: object,
+) -> None:
+ """With SOFT_DELETE off, the response says the delete is permanent."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+ mock_flag.return_value = False
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["soft_deleted"] is False
+ assert "permanent" in (content["message"] or "").lower()
+
+
+@patch(_COUNT, return_value=(3, 2))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_reports_affected_charts(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ """Dependent charts and dashboards are counted in the response."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["affected_chart_count"] == 3
+ assert content["affected_dashboard_count"] == 2
+ assert "3 chart(s)" in (content["message"] or "")
+
+
+def test_count_affected_objects_only_counts_accessible() -> None:
+ """Counts must not disclose charts/dashboards the caller cannot access."""
+ from superset.mcp_service.dataset.tool.delete_dataset import (
+ _count_affected_objects,
+ )
+
+ visible_chart, hidden_chart = Mock(), Mock()
+ visible_dashboard, hidden_dashboard = Mock(), Mock()
+ security_manager = Mock()
+ security_manager.can_access_chart.side_effect = lambda c: c is visible_chart
+ security_manager.can_access_dashboard.side_effect = lambda d: d is visible_dashboard
+
+ with (
+ patch(
+ "superset.daos.dataset.DatasetDAO.get_related_objects",
+ return_value={
+ "charts": [visible_chart, hidden_chart],
+ "dashboards": [visible_dashboard, hidden_dashboard],
+ },
+ ) as mock_related,
+ patch("superset.security_manager", security_manager),
+ ):
+ assert _count_affected_objects(10) == (1, 1)
+
+ mock_related.assert_called_once_with(10)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_permission_denied(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ """A caller who may not delete the dataset gets Forbidden before any
+ dependents are counted or anything is deleted."""
+ from superset.commands.dataset.exceptions import DatasetForbiddenError
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Inline import in test</b></div>
<div id="fix">
Same BITO rule 12745: `DatasetForbiddenError` is imported inline in `test_delete_dataset_permission_denied` with no documented circular-dependency justification. `delete_dataset.py` already imports it at module level, so a top-level import in this test file is safe and consistent with the header imports.
</div>
</div>
<small><i>Code Review Run #89cf94</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
##########
tests/unit_tests/mcp_service/dataset/tool/test_delete_dataset.py:
##########
@@ -0,0 +1,344 @@
+# 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 the delete_dataset MCP tool.
+
+Run through the async MCP Client (not direct calls); auth is mocked via the
+autouse mock_auth fixture, matching the other dataset tool test files.
+"""
+
+from collections.abc import Iterator
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+
+_RESOLVE = "superset.mcp_service.dataset.tool.delete_dataset.resolve_dataset"
+_COUNT = "superset.mcp_service.dataset.tool.delete_dataset._count_affected_objects"
+_RUN = "superset.commands.dataset.delete.DeleteDatasetCommand.run"
+_VALIDATE = "superset.commands.dataset.delete.DeleteDatasetCommand.validate"
+_FLAG = "superset.mcp_service.dataset.tool.delete_dataset.is_feature_enabled"
+
+
+@pytest.fixture
+def mcp_server() -> object:
+ """Provide the FastMCP app instance under test."""
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth() -> Iterator[Mock]:
+ """Authenticate every tool call as a mock admin user."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ # The tool validates the command (lookup + editorship) before counting
+ # dependents; default it to a pass so tests that patch run() alone keep
+ # working. The permission test re-patches it to raise.
+ with patch(_VALIDATE):
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+def _mock_dataset(dataset_id: int = 10, table_name: str = "orders") -> Mock:
+ """Build a minimal dataset stand-in with the attributes the tool reads."""
+ dataset = Mock()
+ dataset.id = dataset_id
+ dataset.table_name = table_name
+ return dataset
+
+
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_not_found(mock_resolve: Mock, mcp_server: object) -> None:
+ """An identifier that resolves to no dataset returns NotFound naming it."""
+ mock_resolve.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 999}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is False
+ assert content["error_type"] == "NotFound"
+ assert "999" in (content["error"] or "")
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_success(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ """The happy path: the dataset is resolved, deleted and its dependents counted."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["deleted_id"] == 10
+ assert "orders" in content["deleted_name"]
+ assert content["permission_denied"] is False
+ assert content["affected_chart_count"] == 0
+ mock_run.assert_called_once()
+ mock_count.assert_called_once_with(10)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_by_uuid(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ """A UUID identifier is passed through to the dataset lookup unchanged."""
+ uuid = "11111111-2222-3333-4444-555555555555"
+ mock_resolve.return_value = _mock_dataset(dataset_id=10)
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": uuid}}
+ )
+
+ assert result.structured_content["success"] is True
+ mock_resolve.assert_called_once_with(uuid)
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_FLAG)
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_soft_delete_reports_restorable(
+ mock_resolve: Mock,
+ mock_run: Mock,
+ mock_flag: Mock,
+ mock_count: Mock,
+ mcp_server: object,
+) -> None:
+ """With SOFT_DELETE on, the response says the dataset can be restored."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+ mock_flag.side_effect = lambda flag: flag == "SOFT_DELETE"
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["soft_deleted"] is True
+ assert "restor" in (content["message"] or "").lower()
+
+
+@patch(_COUNT, return_value=(0, 0))
+@patch(_FLAG)
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_hard_delete_reports_permanent(
+ mock_resolve: Mock,
+ mock_run: Mock,
+ mock_flag: Mock,
+ mock_count: Mock,
+ mcp_server: object,
+) -> None:
+ """With SOFT_DELETE off, the response says the delete is permanent."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+ mock_run.return_value = None
+ mock_flag.return_value = False
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["soft_deleted"] is False
+ assert "permanent" in (content["message"] or "").lower()
+
+
+@patch(_COUNT, return_value=(3, 2))
+@patch(_RUN)
+@patch(_RESOLVE)
+@pytest.mark.asyncio
+async def test_delete_dataset_reports_affected_charts(
+ mock_resolve: Mock, mock_run: Mock, mock_count: Mock, mcp_server: object
+) -> None:
+ """Dependent charts and dashboards are counted in the response."""
+ mock_resolve.return_value = _mock_dataset(dataset_id=10, table_name="orders")
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "delete_dataset", {"request": {"identifier": 10}}
+ )
+
+ content = result.structured_content
+ assert content["success"] is True
+ assert content["affected_chart_count"] == 3
+ assert content["affected_dashboard_count"] == 2
+ assert "3 chart(s)" in (content["message"] or "")
+
+
+def test_count_affected_objects_only_counts_accessible() -> None:
+ """Counts must not disclose charts/dashboards the caller cannot access."""
+ from superset.mcp_service.dataset.tool.delete_dataset import (
+ _count_affected_objects,
+ )
+
+ visible_chart, hidden_chart = Mock(), Mock()
+ visible_dashboard, hidden_dashboard = Mock(), Mock()
+ security_manager = Mock()
+ security_manager.can_access_chart.side_effect = lambda c: c is visible_chart
+ security_manager.can_access_dashboard.side_effect = lambda d: d is visible_dashboard
Review Comment:
<div>
<div id="suggestion">
<div id="issue"><b>Untyped lambda side effects</b></div>
<div id="fix">
BITO rule 13350 bans untyped lambdas: `lambda c: c is visible_chart` and `lambda d: d is visible_dashboard` used as `side_effect` carry no annotations. Extract named helpers with explicit parameter/return types so the mock contract is type-checkable and the visibility predicate is reusable/testable.
</div>
</div>
<small><i>Code Review Run #89cf94</i></small>
</div>
---
Should Bito avoid suggestions like this for future reviews? (<a href=https://alpha.bito.ai/home/ai-agents/review-rules>Manage Rules</a>)
- [ ] Yes, avoid them
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "codeant-ai-for-open-source[bot] (via GitHub)" <gi...@apache.org>.
codeant-ai-for-open-source[bot] commented on code in PR #44337:
URL: https://github.com/apache/superset/pull/44337#discussion_r4089248844
##########
superset/mcp_service/dataset/schemas.py:
##########
@@ -718,6 +739,237 @@ class UpdateDatasetMetricResponse(BaseModel):
)
+class DeleteDatasetRequest(BaseModel):
+ """Request schema for delete_dataset."""
+
+ identifier: int | str = Field(
+ ...,
+ description=(
+ "Dataset identifier - numeric ID or UUID string (NOT the table name)."
+ ),
+ )
+
+ @field_validator("identifier", mode="before")
+ @classmethod
+ def reject_bool_identifier(cls, value: object) -> object:
+ """bool is a subclass of int, so identifier=true would coerce to
+ dataset ID 1 and delete the wrong object; reject it outright."""
+ if isinstance(value, bool):
+ raise ValueError("identifier must be an integer ID or UUID string")
+ return value
+
+
+class DeleteDatasetResponse(BaseModel):
+ """Result of a delete_dataset operation."""
+
+ success: bool = Field(description="Whether the dataset was deleted")
+ deleted_id: int | None = Field(None, description="ID of the deleted dataset")
+ deleted_name: str | None = Field(
+ None, description="Table name of the deleted dataset"
+ )
+ soft_deleted: bool = Field(
+ False,
+ description=(
+ "True when the dataset was soft-deleted (moved to trash, because the "
+ "SOFT_DELETE feature flag is enabled) and can be restored by an "
+ "owner or Admin. False means the delete was permanent."
+ ),
+ )
+ affected_chart_count: int = Field(
+ 0,
+ description=(
+ "Number of charts (visible to the caller) built on this dataset. "
+ "They stop working while the dataset is deleted."
+ ),
+ )
+ affected_dashboard_count: int = Field(
+ 0,
+ description=(
+ "Number of dashboards (visible to the caller) containing those charts."
+ ),
+ )
+ message: str | None = Field(None, description="Human-readable outcome message")
+ error: str | None = Field(None, description="Error message if the delete failed")
+ error_type: str | None = Field(None, description="Type of error if failed")
+ permission_denied: bool = Field(
+ False,
+ description=(
+ "True when the caller lacks permission to delete the dataset (do not "
+ "retry; ask the user)."
+ ),
+ )
+
+
+class RestoreDatasetRequest(BaseModel):
+ """Request schema for restore_dataset."""
+
+ identifier: int | str = Field(
+ ...,
+ description=(
+ "Dataset identifier - numeric ID or UUID string (NOT the table name)."
+ ),
+ )
+
+ @field_validator("identifier", mode="before")
+ @classmethod
+ def reject_bool_identifier(cls, value: object) -> object:
+ """bool is a subclass of int, so identifier=true would coerce to
+ dataset ID 1 and target the wrong object; reject it outright."""
+ if isinstance(value, bool):
+ raise ValueError("identifier must be an integer ID or UUID string")
+ return value
+
+
+class RestoreDatasetResponse(BaseModel):
+ """Result of a restore_dataset operation."""
+
+ success: bool = Field(description="Whether the dataset was restored from trash")
+ restored_id: int | None = Field(None, description="ID of the restored dataset")
+ restored_name: str | None = Field(
+ None, description="Table name of the restored dataset"
+ )
+ message: str | None = Field(None, description="Human-readable outcome message")
+ error: str | None = Field(None, description="Error message if the restore failed")
+ error_type: str | None = Field(None, description="Type of error if failed")
+ permission_denied: bool = Field(
+ False,
+ description=(
+ "True when the caller lacks permission to restore the dataset (do not "
+ "retry; ask the user)."
+ ),
+ )
+
+
+UPDATABLE_DATASET_FIELDS: frozenset[str] = frozenset(
+ {
+ "table_name",
+ "sql",
+ "description",
+ "main_dttm_col",
+ "cache_timeout",
+ }
+)
+
+
+class UpdateDatasetRequest(BaseModel):
+ """Request schema for update_dataset."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ dataset_id: int | str = Field(
+ ...,
+ description="Dataset identifier — numeric ID or UUID string. "
+ "Use list_datasets to find valid IDs.",
+ )
+ table_name: str | None = Field(
+ None,
+ max_length=250,
+ description="New dataset name. For a virtual dataset this is just its "
+ "label; for a physical dataset it must match an existing table.",
+ )
+ sql: str | None = Field(
+ None,
+ description="New SQL for a virtual dataset. Rejected for physical "
+ "datasets. Columns are re-synced from the new query unless "
+ "sync_columns is false.",
+ )
+ description: str | None = Field(None, description="Dataset description.")
+ main_dttm_col: str | None = Field(
+ None,
+ description="Default datetime column; must be one of the dataset's "
+ "columns (after re-sync, when columns are re-synced).",
+ )
+ cache_timeout: int | None = Field(
+ None,
+ ge=-1,
+ description="Cache timeout in seconds. 0 means the cache never expires, "
+ "-1 bypasses the cache, null falls back to the database default.",
+ )
Review Comment:
✅ **CodeAnt verified this suggestion was addressed in subsequent commits and marked this thread resolved** as of `4321f80`.
Added a pre-validation validator for `cache_timeout` that rejects boolean values before Pydantic can coerce them to integers.
<sub>If that's not right, unresolve this thread and CodeAnt will leave it open.</sub>
<!-- codeant-auto-resolve-reply -->
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "tien238lnd (via GitHub)" <gi...@apache.org>.
tien238lnd commented on PR #44337:
URL: https://github.com/apache/superset/pull/44337#issuecomment-5806494719
Added 4321f8075: the two inline imports in `test_delete_dataset.py` move to module level, and the new `deleted_state` test gets a docstring (Bito). I kept the two `side_effect` lambdas — see that thread.
On the coordination point Amin raised: #44573 now also carries a test that walks every subclass of `OmittedMeansUnchanged`, so once one of the two PRs lands, the other only needs `UpdateDatasetRequest` to take the base and the check applies by itself. I will do that in whichever PR merges second rather than add a cross-PR dependency now.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "aminghadersohi (via GitHub)" <gi...@apache.org>.
aminghadersohi commented on code in PR #44337:
URL: https://github.com/apache/superset/pull/44337#discussion_r4082128780
##########
superset/mcp_service/dataset/schemas.py:
##########
@@ -750,6 +771,246 @@ class UpdateDatasetMetricResponse(BaseModel):
)
+class DeleteDatasetRequest(BaseModel):
+ """Request schema for delete_dataset."""
+
+ identifier: int | str = Field(
+ ...,
+ description=(
+ "Dataset identifier - numeric ID or UUID string (NOT the table name)."
+ ),
+ )
+
+ @field_validator("identifier", mode="before")
+ @classmethod
+ def reject_bool_identifier(cls, value: object) -> object:
+ """bool is a subclass of int, so identifier=true would coerce to
+ dataset ID 1 and delete the wrong object; reject it outright."""
+ if isinstance(value, bool):
+ raise ValueError("identifier must be an integer ID or UUID string")
+ return value
+
+
+class DeleteDatasetResponse(BaseModel):
+ """Result of a delete_dataset operation."""
+
+ success: bool = Field(description="Whether the dataset was deleted")
+ deleted_id: int | None = Field(None, description="ID of the deleted dataset")
+ deleted_name: str | None = Field(
+ None, description="Table name of the deleted dataset"
+ )
+ soft_deleted: bool = Field(
+ False,
+ description=(
+ "True when the dataset was soft-deleted (moved to trash, because the "
+ "SOFT_DELETE feature flag is enabled) and can be restored by an "
+ "owner or Admin. False means the delete was permanent."
+ ),
+ )
+ affected_chart_count: int = Field(
+ 0,
+ description=(
+ "Number of charts (visible to the caller) built on this dataset. "
+ "They stop working while the dataset is deleted."
+ ),
+ )
+ affected_dashboard_count: int = Field(
+ 0,
+ description=(
+ "Number of dashboards (visible to the caller) containing those charts."
+ ),
+ )
+ message: str | None = Field(None, description="Human-readable outcome message")
+ error: str | None = Field(None, description="Error message if the delete failed")
+ error_type: str | None = Field(None, description="Type of error if failed")
+ permission_denied: bool = Field(
+ False,
+ description=(
+ "True when the caller lacks permission to delete the dataset (do not "
+ "retry; ask the user)."
+ ),
+ )
+
+
+class RestoreDatasetRequest(BaseModel):
+ """Request schema for restore_dataset."""
+
+ identifier: int | str = Field(
+ ...,
+ description=(
+ "Dataset identifier - numeric ID or UUID string (NOT the table name)."
+ ),
+ )
+
+ @field_validator("identifier", mode="before")
+ @classmethod
+ def reject_bool_identifier(cls, value: object) -> object:
+ """bool is a subclass of int, so identifier=true would coerce to
+ dataset ID 1 and target the wrong object; reject it outright."""
+ if isinstance(value, bool):
+ raise ValueError("identifier must be an integer ID or UUID string")
+ return value
+
+
+class RestoreDatasetResponse(BaseModel):
+ """Result of a restore_dataset operation."""
+
+ success: bool = Field(description="Whether the dataset was restored from trash")
+ restored_id: int | None = Field(None, description="ID of the restored dataset")
+ restored_name: str | None = Field(
+ None, description="Table name of the restored dataset"
+ )
+ message: str | None = Field(None, description="Human-readable outcome message")
+ error: str | None = Field(None, description="Error message if the restore failed")
+ error_type: str | None = Field(None, description="Type of error if failed")
+ permission_denied: bool = Field(
+ False,
+ description=(
+ "True when the caller lacks permission to restore the dataset (do not "
+ "retry; ask the user)."
+ ),
+ )
+
+
+UPDATABLE_DATASET_FIELDS: frozenset[str] = frozenset(
+ {
+ "table_name",
+ "sql",
+ "description",
+ "main_dttm_col",
+ "cache_timeout",
+ }
+)
+
+
+class UpdateDatasetRequest(BaseModel):
Review Comment:
#44573's `OMITTED_MEANS_UNCHANGED_TOOLS` is a hardcoded tuple of the three master tools, so it won't cover `update_dataset` when this lands — unlike the derived `COMMITTED_WRITE_SPECS` test that forced 47ee13df. Whichever merges second has to add it there by hand.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "tien238lnd (via GitHub)" <gi...@apache.org>.
tien238lnd commented on code in PR #44337:
URL: https://github.com/apache/superset/pull/44337#discussion_r4034830540
##########
superset/mcp_service/dataset/schemas.py:
##########
@@ -718,6 +739,237 @@ class UpdateDatasetMetricResponse(BaseModel):
)
+class DeleteDatasetRequest(BaseModel):
+ """Request schema for delete_dataset."""
+
+ identifier: int | str = Field(
+ ...,
+ description=(
+ "Dataset identifier - numeric ID or UUID string (NOT the table name)."
+ ),
+ )
+
+ @field_validator("identifier", mode="before")
+ @classmethod
+ def reject_bool_identifier(cls, value: object) -> object:
+ """bool is a subclass of int, so identifier=true would coerce to
+ dataset ID 1 and delete the wrong object; reject it outright."""
+ if isinstance(value, bool):
+ raise ValueError("identifier must be an integer ID or UUID string")
+ return value
+
+
+class DeleteDatasetResponse(BaseModel):
+ """Result of a delete_dataset operation."""
+
+ success: bool = Field(description="Whether the dataset was deleted")
+ deleted_id: int | None = Field(None, description="ID of the deleted dataset")
+ deleted_name: str | None = Field(
+ None, description="Table name of the deleted dataset"
+ )
+ soft_deleted: bool = Field(
+ False,
+ description=(
+ "True when the dataset was soft-deleted (moved to trash, because the "
+ "SOFT_DELETE feature flag is enabled) and can be restored by an "
+ "owner or Admin. False means the delete was permanent."
+ ),
+ )
+ affected_chart_count: int = Field(
+ 0,
+ description=(
+ "Number of charts (visible to the caller) built on this dataset. "
+ "They stop working while the dataset is deleted."
+ ),
+ )
+ affected_dashboard_count: int = Field(
+ 0,
+ description=(
+ "Number of dashboards (visible to the caller) containing those charts."
+ ),
+ )
+ message: str | None = Field(None, description="Human-readable outcome message")
+ error: str | None = Field(None, description="Error message if the delete failed")
+ error_type: str | None = Field(None, description="Type of error if failed")
+ permission_denied: bool = Field(
+ False,
+ description=(
+ "True when the caller lacks permission to delete the dataset (do not "
+ "retry; ask the user)."
+ ),
+ )
+
+
+class RestoreDatasetRequest(BaseModel):
+ """Request schema for restore_dataset."""
+
+ identifier: int | str = Field(
+ ...,
+ description=(
+ "Dataset identifier - numeric ID or UUID string (NOT the table name)."
+ ),
+ )
+
+ @field_validator("identifier", mode="before")
+ @classmethod
+ def reject_bool_identifier(cls, value: object) -> object:
+ """bool is a subclass of int, so identifier=true would coerce to
+ dataset ID 1 and target the wrong object; reject it outright."""
+ if isinstance(value, bool):
+ raise ValueError("identifier must be an integer ID or UUID string")
+ return value
+
+
+class RestoreDatasetResponse(BaseModel):
+ """Result of a restore_dataset operation."""
+
+ success: bool = Field(description="Whether the dataset was restored from trash")
+ restored_id: int | None = Field(None, description="ID of the restored dataset")
+ restored_name: str | None = Field(
+ None, description="Table name of the restored dataset"
+ )
+ message: str | None = Field(None, description="Human-readable outcome message")
+ error: str | None = Field(None, description="Error message if the restore failed")
+ error_type: str | None = Field(None, description="Type of error if failed")
+ permission_denied: bool = Field(
+ False,
+ description=(
+ "True when the caller lacks permission to restore the dataset (do not "
+ "retry; ask the user)."
+ ),
+ )
+
+
+UPDATABLE_DATASET_FIELDS: frozenset[str] = frozenset(
+ {
+ "table_name",
+ "sql",
+ "description",
+ "main_dttm_col",
+ "cache_timeout",
+ }
+)
+
+
+class UpdateDatasetRequest(BaseModel):
+ """Request schema for update_dataset."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ dataset_id: int | str = Field(
+ ...,
+ description="Dataset identifier — numeric ID or UUID string. "
+ "Use list_datasets to find valid IDs.",
+ )
+ table_name: str | None = Field(
+ None,
+ max_length=250,
+ description="New dataset name. For a virtual dataset this is just its "
+ "label; for a physical dataset it must match an existing table.",
+ )
+ sql: str | None = Field(
+ None,
+ description="New SQL for a virtual dataset. Rejected for physical "
+ "datasets. Columns are re-synced from the new query unless "
+ "sync_columns is false.",
+ )
+ description: str | None = Field(None, description="Dataset description.")
+ main_dttm_col: str | None = Field(
+ None,
+ description="Default datetime column; must be one of the dataset's "
+ "columns (after re-sync, when columns are re-synced).",
+ )
+ cache_timeout: int | None = Field(
+ None,
+ ge=-1,
+ description="Cache timeout in seconds. 0 means the cache never expires, "
+ "-1 bypasses the cache, null falls back to the database default.",
+ )
Review Comment:
Fixed in a7739e4aa9: `cache_timeout` now rejects booleans before Pydantic coerces them. Covered by `test_request_rejects_boolean_cache_timeout`.
##########
superset/mcp_service/dataset/tool/restore_dataset.py:
##########
@@ -0,0 +1,196 @@
+# 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.
+
+"""
+MCP tool: restore_dataset
+"""
+
+import logging
+from typing import Any
+
+from fastmcp import Context
+from sqlalchemy.exc import SQLAlchemyError
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.commands.dataset.exceptions import (
+ DatasetForbiddenError,
+ DatasetLogicalDuplicateError,
+ DatasetNotFoundError,
+)
+from superset.commands.exceptions import CommandException
+from superset.extensions import event_logger
+from superset.mcp_service.dataset.schemas import (
+ RestoreDatasetRequest,
+ RestoreDatasetResponse,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _find_dataset_for_restore(identifier: int | str) -> Any | None:
+ """Resolve a dataset by numeric ID or UUID, including soft-deleted rows.
+
+ Both bypasses mirror ``BaseRestoreCommand.validate``'s own lookup:
+ ``skip_visibility_filter`` unhides the soft-deleted row, and
+ ``skip_base_filter`` keeps an editor's own trash reachable even when the
+ dataset's datasource-access base_filter would hide it (a lost grant must
+ not hide a row from the one audience that can restore it). The restore
+ audience is enforced by ``RestoreDatasetCommand`` via
+ ``raise_for_editorship``.
+ """
+ from superset.daos.dataset import DatasetDAO
+
+ return DatasetDAO.find_by_id_or_uuid(
+ str(identifier),
+ skip_base_filter=True,
+ skip_visibility_filter=True,
+ )
+
+
+def _rollback() -> None:
+ from superset import db
+
+ try:
+ db.session.rollback() # pylint: disable=consider-using-transaction
+ except SQLAlchemyError:
+ logger.warning("Database rollback failed during restore_dataset error handling")
+
+
+@tool(
+ tags=["mutate"],
+ class_permission_name="Dataset",
+ annotations=ToolAnnotations(
+ title="Restore dataset",
+ readOnlyHint=False,
+ destructiveHint=False,
+ idempotentHint=False,
+ openWorldHint=False,
+ ),
+)
+async def restore_dataset(
+ request: RestoreDatasetRequest, ctx: Context
+) -> RestoreDatasetResponse:
+ """Restore a soft-deleted dataset from trash.
+
+ Identify the dataset by numeric ID or UUID string (NOT table name). Only
+ datasets that were soft-deleted (moved to trash while the ``SOFT_DELETE``
+ feature flag was enabled) can be restored; permanently deleted datasets
+ are unrecoverable. The caller must be an editor of the dataset (owners
+ and Admins qualify). Use list_datasets with deleted_state='only' to find
+ trashed datasets.
+
+ Restoring fails with ``error_type`` ``LogicalDuplicate`` when another
+ active dataset already points at the same physical table; that dataset
+ must be deleted or renamed first.
+
+ Example:
+ ```json
+ {"identifier": 123}
+ ```
+
+ Returns success with the restored dataset's id/name, or an error. When the
+ caller lacks permission, ``permission_denied`` is true — do not retry; ask
+ the user.
+ """
+ await ctx.info("Restoring dataset: identifier=%s" % (request.identifier,))
+
+ try:
+ dataset = _find_dataset_for_restore(request.identifier)
+ except SQLAlchemyError:
+ _rollback()
+ logger.exception("Dataset lookup failed during restore_dataset")
+ return RestoreDatasetResponse(
+ success=False,
+ error="Dataset lookup failed due to a database error.",
+ error_type="LookupFailed",
+ )
+ if not dataset:
+ display_id = str(request.identifier)[:200]
+ msg = f"No dataset found with identifier: {display_id}."
+ return RestoreDatasetResponse(success=False, error=msg, error_type="NotFound")
+
+ dataset_id = dataset.id
+ # Table names are user-controlled and must remain exact in response text.
+ dataset_name = dataset.table_name
+
+ if dataset.deleted_at is None:
+ return RestoreDatasetResponse(
+ success=False,
+ error=(
+ f"Dataset '{dataset_name}' (id={dataset_id}) is not in trash; "
+ "nothing to restore."
+ ),
+ error_type="NotDeleted",
+ )
+
+ # The try/except sits inside log_context so failed restore attempts are
+ # recorded in the audit log too — the context manager does not log when
+ # an exception propagates through it.
+ with event_logger.log_context(action="mcp.restore_dataset"):
+ try:
+ from superset.commands.dataset.restore import RestoreDatasetCommand
+
+ RestoreDatasetCommand(str(dataset.uuid)).run()
+
+ return RestoreDatasetResponse(
+ success=True,
+ restored_id=dataset_id,
+ restored_name=dataset_name,
+ message=(
+ f"Restored dataset '{dataset_name}' (id={dataset_id}) from trash."
+ ),
+ )
+ except DatasetForbiddenError:
+ await ctx.warning(
+ "Permission denied restoring dataset id=%s" % (dataset_id,)
+ )
+ return RestoreDatasetResponse(
+ success=False,
+ permission_denied=True,
+ error=(
+ "You do not have permission to restore dataset "
+ f"'{dataset_name}' (id={dataset_id}). Ask the user to restore "
+ "it or grant access; do not retry."
+ ),
+ error_type="Forbidden",
+ )
+ except DatasetLogicalDuplicateError as ex:
Review Comment:
Fixed in a7739e4aa9. `restore_dataset` now checks editorship right after the lookup, before any response that names the dataset, the same way `restore_chart` does: a dataset outside the caller's RBAC scope reads as `NotFound`, and a visible one the caller cannot edit gets `Forbidden` with the id only. Covered by `test_restore_dataset_inaccessible_dataset_reads_as_not_found` (trashed and live datasets) and `test_restore_dataset_visible_non_editor_gets_nameless_forbidden`.
##########
superset/mcp_service/dataset/tool/update_dataset.py:
##########
@@ -0,0 +1,289 @@
+# 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.
+
+"""
+MCP tool: update_dataset
+"""
+
+import logging
+from typing import Any
+
+from fastmcp import Context
+from sqlalchemy.exc import SQLAlchemyError
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset import security_manager
+from superset.exceptions import SupersetException
+from superset.extensions import event_logger
+from superset.mcp_service.dataset.schemas import (
+ UpdateDatasetRequest,
+ UpdateDatasetResponse,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _column_names(dataset: Any) -> set[str]:
+ return {column.column_name for column in dataset.columns}
+
+
+def _sync_error_message(ex: Exception) -> str:
+ # Raw SQLAlchemy text can leak SQL or connection details; Superset
+ # exception messages are user-facing by design.
+ if isinstance(ex, SQLAlchemyError):
+ return "a database error occurred"
+ return str(ex)
+
+
+@tool(
+ tags=["mutate"],
+ class_permission_name="Dataset",
+ method_permission_name="write",
+ annotations=ToolAnnotations(
+ title="Update dataset",
+ readOnlyHint=False,
+ # Rewriting a virtual dataset's SQL or re-syncing its columns changes
+ # what every chart built on it queries — non-additive, like
+ # update_chart.
+ destructiveHint=True,
+ idempotentHint=False,
+ openWorldHint=False,
+ ),
+)
+async def update_dataset( # noqa: C901
+ request: UpdateDatasetRequest, ctx: Context
+) -> UpdateDatasetResponse:
+ """Update a dataset's name, SQL, description, default datetime column or
+ cache timeout, and optionally re-sync its columns from the data source.
+
+ Only the properties you pass are changed. ``sql`` applies to virtual
+ datasets only. When ``sql`` changes, columns are re-synced from the new
+ query (like "Sync columns from source" in the dataset editor) unless
+ ``sync_columns`` is false; pass ``sync_columns=true`` on its own to pick
+ up schema changes in the underlying table or query. Calculated columns
+ and saved metrics are kept. Use update_dataset_metric to edit metrics.
+ Requires ownership of the dataset (or Admin).
+
+ Check ``removed_columns`` in the response: charts that use those columns
+ fail until they are updated. ``warnings`` reports problems that did not
+ undo the update, e.g. a column re-sync that failed after the SQL was saved.
+
+ Workflow:
+ 1. Call get_dataset_info to inspect the dataset
+ 2. Call this tool with the dataset ID and only the properties to change
+
+ Example usage:
+ ```json
+ {
+ "dataset_id": 123,
+ "sql": "SELECT region, SUM(revenue) AS revenue FROM sales GROUP BY region",
+ "description": "Revenue by region"
+ }
+ ```
+ """
+ updates = request.updates()
+ await ctx.info(
+ "Updating dataset: dataset_id=%s, properties=%s, sync_columns=%s"
+ % (request.dataset_id, sorted(updates), request.sync_columns)
+ )
+
+ try:
+ from sqlalchemy.orm import joinedload, subqueryload
+
+ from superset.commands.dataset.exceptions import (
+ DatasetForbiddenError,
+ DatasetInvalidError,
+ DatasetNotFoundError,
+ DatasetUpdateFailedError,
+ )
+ from superset.commands.dataset.refresh import RefreshDatasetCommand
+ from superset.commands.dataset.update import UpdateDatasetCommand
+ from superset.connectors.sqla.models import SqlaTable
+ from superset.exceptions import SupersetSecurityException
+ from superset.mcp_service.dataset.dataset_utils import resolve_dataset
+ from superset.mcp_service.utils.url_utils import get_superset_base_url
+
+ eager_options = [
+ subqueryload(SqlaTable.columns),
+ joinedload(SqlaTable.database),
+ ]
+
+ with event_logger.log_context(action="mcp.update_dataset.lookup"):
+ dataset = resolve_dataset(request.dataset_id, eager_options)
+
+ if dataset is None:
+ display_id = str(request.dataset_id)[:200]
+ await ctx.warning("Dataset not found: %s" % (display_id,))
+ return UpdateDatasetResponse(
+ error=(
+ f"No dataset found with identifier: {display_id}."
+ " Use list_datasets to get valid dataset IDs."
+ ),
+ )
+
+ dataset_id = dataset.id
+
+ # Enforce editorship before validating against the dataset's columns,
+ # so a caller without edit rights learns nothing beyond "forbidden".
+ # UpdateDatasetCommand and RefreshDatasetCommand re-check this.
+ try:
+ security_manager.raise_for_editorship(dataset)
+ except SupersetSecurityException:
+ await ctx.warning("Dataset update forbidden: dataset_id=%s" % (dataset_id,))
+ return UpdateDatasetResponse(
+ dataset_id=dataset_id,
+ permission_denied=True,
+ error="You must be an owner of this dataset (or an Admin) to "
+ "update it. Ask the user to update it or grant access; do not "
+ "retry.",
+ )
+
+ if "sql" in updates and not dataset.sql:
+ return UpdateDatasetResponse(
+ dataset_id=dataset_id,
+ error="sql can only be set on a virtual dataset; this dataset "
+ "is a physical table.",
+ )
+
+ sync_columns = (
+ request.sync_columns
+ if request.sync_columns is not None
+ else "sql" in updates and updates["sql"] != dataset.sql
+ )
+
+ # A new default datetime column is checked against the columns the
+ # dataset will have once the update is done: the current ones, or the
+ # re-synced ones, in which case it is applied after the sync.
+ pending_dttm_col = None
+ if updates.get("main_dttm_col") is not None:
+ if sync_columns:
+ pending_dttm_col = updates.pop("main_dttm_col")
+ elif updates["main_dttm_col"] not in _column_names(dataset):
+ dttm_col = updates["main_dttm_col"]
+ return UpdateDatasetResponse(
+ dataset_id=dataset_id,
+ error=f"main_dttm_col '{dttm_col}' is not a column of this "
+ "dataset. Use get_dataset_info to list its columns.",
+ )
+
+ columns_before = _column_names(dataset)
+ updated_properties = sorted(updates)
+
+ if updates:
+ # Same pair of commands as PUT /api/v1/dataset/<pk>?override_columns=
+ # — the update commits before the column refresh runs.
+ with event_logger.log_context(action="mcp.update_dataset.update"):
+ dataset = UpdateDatasetCommand(
+ dataset_id, updates, override_columns=sync_columns
+ ).run()
+
+ warnings: list[str] = []
+ added_columns: list[str] = []
+ removed_columns: list[str] = []
+ columns_synced = False
+ if sync_columns:
+ try:
+ with event_logger.log_context(action="mcp.update_dataset.sync_columns"):
+ dataset = RefreshDatasetCommand(dataset_id).run()
+ columns_after = _column_names(dataset)
+ added_columns = sorted(columns_after - columns_before)
+ removed_columns = sorted(columns_before - columns_after)
+ columns_synced = True
+ except (SupersetException, SQLAlchemyError) as ex:
+ await ctx.warning(
+ "Dataset column sync failed: %s: %s" % (type(ex).__name__, ex)
+ )
+ warnings.append(
+ "The update was saved, but re-syncing columns failed "
+ f"({_sync_error_message(ex)}). The column list may not "
+ "match the dataset's SQL; retry with sync_columns=true."
+ )
+
+ if pending_dttm_col is not None:
+ if not columns_synced:
+ warnings.append(
+ f"main_dttm_col was not changed to '{pending_dttm_col}' "
+ "because the columns could not be re-synced."
+ )
+ elif pending_dttm_col not in _column_names(dataset):
+ warnings.append(
+ f"main_dttm_col was not changed: '{pending_dttm_col}' is not "
+ "a column of the dataset after the update."
+ )
+ else:
+ with event_logger.log_context(action="mcp.update_dataset.update"):
+ dataset = UpdateDatasetCommand(
+ dataset_id, {"main_dttm_col": pending_dttm_col}
+ ).run()
Review Comment:
Fixed in a7739e4aa9: a failure in that second `UpdateDatasetCommand` is now reported as a warning, with `updated_properties` listing what was saved and `columns_synced` reflecting the re-sync. Covered by `test_update_dataset_main_dttm_col_update_failure_is_partial`.
##########
superset/mcp_service/dataset/tool/restore_dataset.py:
##########
@@ -0,0 +1,196 @@
+# 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.
+
+"""
+MCP tool: restore_dataset
+"""
+
+import logging
+from typing import Any
+
+from fastmcp import Context
+from sqlalchemy.exc import SQLAlchemyError
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.commands.dataset.exceptions import (
+ DatasetForbiddenError,
+ DatasetLogicalDuplicateError,
+ DatasetNotFoundError,
+)
+from superset.commands.exceptions import CommandException
+from superset.extensions import event_logger
+from superset.mcp_service.dataset.schemas import (
+ RestoreDatasetRequest,
+ RestoreDatasetResponse,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _find_dataset_for_restore(identifier: int | str) -> Any | None:
+ """Resolve a dataset by numeric ID or UUID, including soft-deleted rows.
+
+ Both bypasses mirror ``BaseRestoreCommand.validate``'s own lookup:
+ ``skip_visibility_filter`` unhides the soft-deleted row, and
+ ``skip_base_filter`` keeps an editor's own trash reachable even when the
+ dataset's datasource-access base_filter would hide it (a lost grant must
+ not hide a row from the one audience that can restore it). The restore
+ audience is enforced by ``RestoreDatasetCommand`` via
+ ``raise_for_editorship``.
+ """
+ from superset.daos.dataset import DatasetDAO
+
+ return DatasetDAO.find_by_id_or_uuid(
+ str(identifier),
+ skip_base_filter=True,
+ skip_visibility_filter=True,
+ )
+
+
+def _rollback() -> None:
+ from superset import db
+
+ try:
+ db.session.rollback() # pylint: disable=consider-using-transaction
+ except SQLAlchemyError:
+ logger.warning("Database rollback failed during restore_dataset error handling")
+
+
+@tool(
+ tags=["mutate"],
+ class_permission_name="Dataset",
+ annotations=ToolAnnotations(
+ title="Restore dataset",
+ readOnlyHint=False,
+ destructiveHint=False,
+ idempotentHint=False,
+ openWorldHint=False,
+ ),
+)
+async def restore_dataset(
+ request: RestoreDatasetRequest, ctx: Context
+) -> RestoreDatasetResponse:
+ """Restore a soft-deleted dataset from trash.
+
+ Identify the dataset by numeric ID or UUID string (NOT table name). Only
+ datasets that were soft-deleted (moved to trash while the ``SOFT_DELETE``
+ feature flag was enabled) can be restored; permanently deleted datasets
+ are unrecoverable. The caller must be an editor of the dataset (owners
+ and Admins qualify). Use list_datasets with deleted_state='only' to find
+ trashed datasets.
+
+ Restoring fails with ``error_type`` ``LogicalDuplicate`` when another
+ active dataset already points at the same physical table; that dataset
+ must be deleted or renamed first.
+
+ Example:
+ ```json
+ {"identifier": 123}
+ ```
+
+ Returns success with the restored dataset's id/name, or an error. When the
+ caller lacks permission, ``permission_denied`` is true — do not retry; ask
+ the user.
+ """
+ await ctx.info("Restoring dataset: identifier=%s" % (request.identifier,))
+
+ try:
+ dataset = _find_dataset_for_restore(request.identifier)
+ except SQLAlchemyError:
+ _rollback()
+ logger.exception("Dataset lookup failed during restore_dataset")
+ return RestoreDatasetResponse(
+ success=False,
+ error="Dataset lookup failed due to a database error.",
+ error_type="LookupFailed",
+ )
+ if not dataset:
+ display_id = str(request.identifier)[:200]
+ msg = f"No dataset found with identifier: {display_id}."
+ return RestoreDatasetResponse(success=False, error=msg, error_type="NotFound")
+
+ dataset_id = dataset.id
+ # Table names are user-controlled and must remain exact in response text.
+ dataset_name = dataset.table_name
+
+ if dataset.deleted_at is None:
Review Comment:
Same fix as the CodeAnt thread on this file: a7739e4aa9 adds the editorship and visibility gate before the `NotDeleted` response, mirroring `restore_chart`. The parametrized test also covers a dataset that is not in trash, which is the path described here.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on code in PR #44337:
URL: https://github.com/apache/superset/pull/44337#discussion_r4038168072
##########
tests/unit_tests/mcp_service/test_rbac_tool_enforcement.py:
##########
@@ -110,6 +110,24 @@
"write",
"Dataset",
),
+ (
+ "update_dataset",
+ {"dataset_id": 1, "description": "denied"},
+ "write",
+ "Dataset",
+ ),
+ (
+ "delete_dataset",
+ {"identifier": 1},
+ "write",
+ "Dataset",
+ ),
+ (
+ "restore_dataset",
+ {"identifier": 1},
+ "write",
+ "Dataset",
+ ),
Review Comment:
<!-- Bito Reply -->
Your analysis is correct. Since `_method_permission_name` is explicitly set to "write" for these tools before the authentication hook is applied, the fallback to "read" does not occur. The test expectation is already aligned with the "write" permission, so the suggestion to change the test expectation is unnecessary and would indeed incorrectly downgrade the permission requirement.
**tests/unit_tests/mcp_service/test_rbac_tool_enforcement.py**
```
(
"delete_dataset",
{"identifier": 1},
"write",
"Dataset",
),
```
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "codeant-ai-for-open-source[bot] (via GitHub)" <gi...@apache.org>.
codeant-ai-for-open-source[bot] commented on code in PR #44337:
URL: https://github.com/apache/superset/pull/44337#discussion_r4059935450
##########
superset/mcp_service/dataset/schemas.py:
##########
@@ -718,6 +739,237 @@ class UpdateDatasetMetricResponse(BaseModel):
)
+class DeleteDatasetRequest(BaseModel):
+ """Request schema for delete_dataset."""
+
+ identifier: int | str = Field(
+ ...,
+ description=(
+ "Dataset identifier - numeric ID or UUID string (NOT the table name)."
+ ),
+ )
+
+ @field_validator("identifier", mode="before")
+ @classmethod
+ def reject_bool_identifier(cls, value: object) -> object:
+ """bool is a subclass of int, so identifier=true would coerce to
+ dataset ID 1 and delete the wrong object; reject it outright."""
+ if isinstance(value, bool):
+ raise ValueError("identifier must be an integer ID or UUID string")
+ return value
+
+
+class DeleteDatasetResponse(BaseModel):
+ """Result of a delete_dataset operation."""
+
+ success: bool = Field(description="Whether the dataset was deleted")
+ deleted_id: int | None = Field(None, description="ID of the deleted dataset")
+ deleted_name: str | None = Field(
+ None, description="Table name of the deleted dataset"
+ )
+ soft_deleted: bool = Field(
+ False,
+ description=(
+ "True when the dataset was soft-deleted (moved to trash, because the "
+ "SOFT_DELETE feature flag is enabled) and can be restored by an "
+ "owner or Admin. False means the delete was permanent."
+ ),
+ )
+ affected_chart_count: int = Field(
+ 0,
+ description=(
+ "Number of charts (visible to the caller) built on this dataset. "
+ "They stop working while the dataset is deleted."
+ ),
+ )
+ affected_dashboard_count: int = Field(
+ 0,
+ description=(
+ "Number of dashboards (visible to the caller) containing those charts."
+ ),
+ )
+ message: str | None = Field(None, description="Human-readable outcome message")
+ error: str | None = Field(None, description="Error message if the delete failed")
+ error_type: str | None = Field(None, description="Type of error if failed")
+ permission_denied: bool = Field(
+ False,
+ description=(
+ "True when the caller lacks permission to delete the dataset (do not "
+ "retry; ask the user)."
+ ),
+ )
+
+
+class RestoreDatasetRequest(BaseModel):
+ """Request schema for restore_dataset."""
+
+ identifier: int | str = Field(
+ ...,
+ description=(
+ "Dataset identifier - numeric ID or UUID string (NOT the table name)."
+ ),
+ )
+
+ @field_validator("identifier", mode="before")
+ @classmethod
+ def reject_bool_identifier(cls, value: object) -> object:
+ """bool is a subclass of int, so identifier=true would coerce to
+ dataset ID 1 and target the wrong object; reject it outright."""
+ if isinstance(value, bool):
+ raise ValueError("identifier must be an integer ID or UUID string")
+ return value
+
+
+class RestoreDatasetResponse(BaseModel):
+ """Result of a restore_dataset operation."""
+
+ success: bool = Field(description="Whether the dataset was restored from trash")
+ restored_id: int | None = Field(None, description="ID of the restored dataset")
+ restored_name: str | None = Field(
+ None, description="Table name of the restored dataset"
+ )
+ message: str | None = Field(None, description="Human-readable outcome message")
+ error: str | None = Field(None, description="Error message if the restore failed")
+ error_type: str | None = Field(None, description="Type of error if failed")
+ permission_denied: bool = Field(
+ False,
+ description=(
+ "True when the caller lacks permission to restore the dataset (do not "
+ "retry; ask the user)."
+ ),
+ )
+
+
+UPDATABLE_DATASET_FIELDS: frozenset[str] = frozenset(
+ {
+ "table_name",
+ "sql",
+ "description",
+ "main_dttm_col",
+ "cache_timeout",
+ }
+)
+
+
+class UpdateDatasetRequest(BaseModel):
+ """Request schema for update_dataset."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ dataset_id: int | str = Field(
+ ...,
+ description="Dataset identifier — numeric ID or UUID string. "
+ "Use list_datasets to find valid IDs.",
+ )
+ table_name: str | None = Field(
+ None,
+ max_length=250,
+ description="New dataset name. For a virtual dataset this is just its "
+ "label; for a physical dataset it must match an existing table.",
+ )
+ sql: str | None = Field(
+ None,
+ description="New SQL for a virtual dataset. Rejected for physical "
+ "datasets. Columns are re-synced from the new query unless "
+ "sync_columns is false.",
+ )
+ description: str | None = Field(None, description="Dataset description.")
+ main_dttm_col: str | None = Field(
+ None,
+ description="Default datetime column; must be one of the dataset's "
+ "columns (after re-sync, when columns are re-synced).",
+ )
+ cache_timeout: int | None = Field(
+ None,
+ ge=-1,
+ description="Cache timeout in seconds. 0 means the cache never expires, "
+ "-1 bypasses the cache, null falls back to the database default.",
+ )
Review Comment:
✅ **CodeAnt verified this suggestion was addressed in subsequent commits and marked this thread resolved** as of `9ded2f2`.
Added a `before` validator for `cache_timeout` that rejects boolean values before Pydantic coerces them to integers.
<sub>If that's not right, unresolve this thread and CodeAnt will leave it open.</sub>
<!-- codeant-auto-resolve-reply -->
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "codeant-ai-for-open-source[bot] (via GitHub)" <gi...@apache.org>.
codeant-ai-for-open-source[bot] commented on code in PR #44337:
URL: https://github.com/apache/superset/pull/44337#discussion_r4101779057
##########
superset/mcp_service/dataset/schemas.py:
##########
@@ -718,6 +739,237 @@ class UpdateDatasetMetricResponse(BaseModel):
)
+class DeleteDatasetRequest(BaseModel):
+ """Request schema for delete_dataset."""
+
+ identifier: int | str = Field(
+ ...,
+ description=(
+ "Dataset identifier - numeric ID or UUID string (NOT the table name)."
+ ),
+ )
+
+ @field_validator("identifier", mode="before")
+ @classmethod
+ def reject_bool_identifier(cls, value: object) -> object:
+ """bool is a subclass of int, so identifier=true would coerce to
+ dataset ID 1 and delete the wrong object; reject it outright."""
+ if isinstance(value, bool):
+ raise ValueError("identifier must be an integer ID or UUID string")
+ return value
+
+
+class DeleteDatasetResponse(BaseModel):
+ """Result of a delete_dataset operation."""
+
+ success: bool = Field(description="Whether the dataset was deleted")
+ deleted_id: int | None = Field(None, description="ID of the deleted dataset")
+ deleted_name: str | None = Field(
+ None, description="Table name of the deleted dataset"
+ )
+ soft_deleted: bool = Field(
+ False,
+ description=(
+ "True when the dataset was soft-deleted (moved to trash, because the "
+ "SOFT_DELETE feature flag is enabled) and can be restored by an "
+ "owner or Admin. False means the delete was permanent."
+ ),
+ )
+ affected_chart_count: int = Field(
+ 0,
+ description=(
+ "Number of charts (visible to the caller) built on this dataset. "
+ "They stop working while the dataset is deleted."
+ ),
+ )
+ affected_dashboard_count: int = Field(
+ 0,
+ description=(
+ "Number of dashboards (visible to the caller) containing those charts."
+ ),
+ )
+ message: str | None = Field(None, description="Human-readable outcome message")
+ error: str | None = Field(None, description="Error message if the delete failed")
+ error_type: str | None = Field(None, description="Type of error if failed")
+ permission_denied: bool = Field(
+ False,
+ description=(
+ "True when the caller lacks permission to delete the dataset (do not "
+ "retry; ask the user)."
+ ),
+ )
+
+
+class RestoreDatasetRequest(BaseModel):
+ """Request schema for restore_dataset."""
+
+ identifier: int | str = Field(
+ ...,
+ description=(
+ "Dataset identifier - numeric ID or UUID string (NOT the table name)."
+ ),
+ )
+
+ @field_validator("identifier", mode="before")
+ @classmethod
+ def reject_bool_identifier(cls, value: object) -> object:
+ """bool is a subclass of int, so identifier=true would coerce to
+ dataset ID 1 and target the wrong object; reject it outright."""
+ if isinstance(value, bool):
+ raise ValueError("identifier must be an integer ID or UUID string")
+ return value
+
+
+class RestoreDatasetResponse(BaseModel):
+ """Result of a restore_dataset operation."""
+
+ success: bool = Field(description="Whether the dataset was restored from trash")
+ restored_id: int | None = Field(None, description="ID of the restored dataset")
+ restored_name: str | None = Field(
+ None, description="Table name of the restored dataset"
+ )
+ message: str | None = Field(None, description="Human-readable outcome message")
+ error: str | None = Field(None, description="Error message if the restore failed")
+ error_type: str | None = Field(None, description="Type of error if failed")
+ permission_denied: bool = Field(
+ False,
+ description=(
+ "True when the caller lacks permission to restore the dataset (do not "
+ "retry; ask the user)."
+ ),
+ )
+
+
+UPDATABLE_DATASET_FIELDS: frozenset[str] = frozenset(
+ {
+ "table_name",
+ "sql",
+ "description",
+ "main_dttm_col",
+ "cache_timeout",
+ }
+)
+
+
+class UpdateDatasetRequest(BaseModel):
+ """Request schema for update_dataset."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ dataset_id: int | str = Field(
+ ...,
+ description="Dataset identifier — numeric ID or UUID string. "
+ "Use list_datasets to find valid IDs.",
+ )
+ table_name: str | None = Field(
+ None,
+ max_length=250,
+ description="New dataset name. For a virtual dataset this is just its "
+ "label; for a physical dataset it must match an existing table.",
+ )
+ sql: str | None = Field(
+ None,
+ description="New SQL for a virtual dataset. Rejected for physical "
+ "datasets. Columns are re-synced from the new query unless "
+ "sync_columns is false.",
+ )
+ description: str | None = Field(None, description="Dataset description.")
+ main_dttm_col: str | None = Field(
+ None,
+ description="Default datetime column; must be one of the dataset's "
+ "columns (after re-sync, when columns are re-synced).",
+ )
+ cache_timeout: int | None = Field(
+ None,
+ ge=-1,
+ description="Cache timeout in seconds. 0 means the cache never expires, "
+ "-1 bypasses the cache, null falls back to the database default.",
+ )
Review Comment:
✅ **CodeAnt verified this suggestion was addressed in subsequent commits and marked this thread resolved** as of `1e4ef69`.
Added a pre-validation guard that rejects boolean values for `cache_timeout` before Pydantic coerces them to integers.
<sub>If that's not right, unresolve this thread and CodeAnt will leave it open.</sub>
<!-- codeant-auto-resolve-reply -->
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(mcp): add update_dataset, delete_dataset and restore_dataset tools [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #44337:
URL: https://github.com/apache/superset/pull/44337#issuecomment-5830154345
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
<details>
<summary>
Bito Usage Guide
</summary>
**Commands**
Type the following command in the pull request comment and save the comment.
- `/review` - Manually triggers an incremental AI Review.
- `/review full` - Manually triggers a full AI Review.
- `/pause` - Pauses automatic reviews on this pull request.
- `/resume` - Resumes automatic reviews.
- `/resolve` - Marks all Bito-posted review comments as resolved.
- `/abort` - Cancels all in-progress reviews.
Refer to the <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/available-commands" target="_blank" rel="noopener noreferrer">documentation</a> for additional commands.
**Configuration**
This repository uses `Superset` You can customize the agent settings <a href="https://alpha.bito.ai/home/ai-agents/code-review-agent" target="_blank" rel="noopener noreferrer">here</a> or contact your Bito workspace admin at evan@preset.io.
**Documentation & Help**
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/getting-started/install-run-using-bito-cloud/create-or-customize-an-agent-instance" target="_blank" rel="noopener noreferrer">Customize agent settings</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/implementing-custom-code-review-rules" target="_blank" rel="noopener noreferrer">Review rules</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent" target="_blank" rel="noopener noreferrer">General documentation</a>
- <a href="https://docs.bito.ai/bito-dev-agents/ai-code-review-agent/faqs" target="_blank" rel="noopener noreferrer">FAQ</a>
</details>
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org