You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by as...@apache.org on 2026/04/30 09:27:37 UTC
(superset) 01/01: feat(mcp): generate bug report tool
This is an automated email from the ASF dual-hosted git repository.
alexandrusoare pushed a commit to branch alexandrusoare/feat/generate_bug_report
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 2a6e73593d882bbd0c1229978a412cc79d625dd2
Author: alexandrusoare <al...@gmail.com>
AuthorDate: Thu Apr 30 12:27:09 2026 +0300
feat(mcp): generate bug report tool
---
superset/mcp_service/app.py | 1 +
superset/mcp_service/middleware.py | 13 ++-
superset/mcp_service/system/schemas.py | 31 +++++
superset/mcp_service/system/tool/__init__.py | 4 +-
.../mcp_service/system/tool/generate_bug_report.py | 95 +++++++++++++++
.../system/tool/test_generate_bug_report.py | 128 +++++++++++++++++++++
.../mcp_service/test_middleware_logging.py | 66 ++++++++++-
7 files changed, 335 insertions(+), 3 deletions(-)
diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py
index e3490450187..b6c3e892e7f 100644
--- a/superset/mcp_service/app.py
+++ b/superset/mcp_service/app.py
@@ -532,6 +532,7 @@ from superset.mcp_service.system import ( # noqa: F401, E402
resources as system_resources,
)
from superset.mcp_service.system.tool import ( # noqa: F401, E402
+ generate_bug_report,
get_instance_info,
get_schema,
health_check,
diff --git a/superset/mcp_service/middleware.py b/superset/mcp_service/middleware.py
index de592a3da02..a7767a03478 100644
--- a/superset/mcp_service/middleware.py
+++ b/superset/mcp_service/middleware.py
@@ -17,6 +17,7 @@
import logging
import time
+import uuid
from collections import defaultdict
from typing import Any, Awaitable, Callable, Dict, Protocol, Sequence
@@ -239,11 +240,19 @@ class LoggingMiddleware(Middleware):
)
tool_name = getattr(context.message, "name", None)
+ mcp_call_id = uuid.uuid4().hex[:12]
start_time = time.time()
success = False
try:
result = await call_next(context)
success = not self._is_error_response(result)
+ if isinstance(result, ToolResult):
+ existing_meta = result.meta or {}
+ result = ToolResult(
+ content=result.content,
+ meta={**existing_meta, "mcp_call_id": mcp_call_id},
+ structured_content=result.structured_content,
+ )
return result
except Exception:
success = False
@@ -259,6 +268,7 @@ class LoggingMiddleware(Middleware):
slice_id=slice_id,
referrer=None,
curated_payload={
+ "mcp_call_id": mcp_call_id,
"tool": tool_name,
"agent_id": agent_id,
"params": _sanitize_params(params),
@@ -272,7 +282,7 @@ class LoggingMiddleware(Middleware):
logger.info(
"MCP tool call: tool=%s, agent_id=%s, user_id=%s, method=%s, "
"dashboard_id=%s, slice_id=%s, dataset_id=%s, duration_ms=%s, "
- "success=%s",
+ "success=%s, mcp_call_id=%s",
tool_name,
agent_id,
user_id,
@@ -282,6 +292,7 @@ class LoggingMiddleware(Middleware):
dataset_id,
duration_ms,
success,
+ mcp_call_id,
)
async def on_message(
diff --git a/superset/mcp_service/system/schemas.py b/superset/mcp_service/system/schemas.py
index 85a78277c6d..b53a22d30b6 100644
--- a/superset/mcp_service/system/schemas.py
+++ b/superset/mcp_service/system/schemas.py
@@ -191,3 +191,34 @@ class PaginationInfo(BaseModel):
has_next: bool
has_previous: bool
model_config = ConfigDict(ser_json_timedelta="iso8601")
+
+
+class GenerateBugReportRequest(BaseModel):
+ """Request schema for generate_bug_report tool."""
+
+ model_config = ConfigDict(
+ extra="forbid",
+ populate_by_name=True,
+ )
+
+ summary: str = Field(..., description="Brief summary of the issue")
+ steps_to_reproduce: str = Field(..., description="Steps to reproduce the issue")
+ expected_behavior: str = Field(..., description="Expected behavior")
+ actual_behavior: str = Field(..., description="Actual behavior observed")
+ mcp_call_id: str | None = Field(
+ None,
+ description=(
+ "Optional MCP call ID from a previous tool invocation. "
+ "When provided, it will be included in the bug report "
+ "for server-side log correlation."
+ ),
+ )
+
+
+class GenerateBugReportResponse(BaseModel):
+ """Response schema for generate_bug_report tool."""
+
+ report: str = Field(..., description="Formatted bug report text")
+ mcp_call_id: str | None = Field(
+ None, description="The MCP call ID if one was provided"
+ )
diff --git a/superset/mcp_service/system/tool/__init__.py b/superset/mcp_service/system/tool/__init__.py
index eb1dfb88013..73d805f7f15 100644
--- a/superset/mcp_service/system/tool/__init__.py
+++ b/superset/mcp_service/system/tool/__init__.py
@@ -17,12 +17,14 @@
"""System tools for MCP service."""
+from .generate_bug_report import generate_bug_report
from .get_instance_info import get_instance_info
from .get_schema import get_schema
from .health_check import health_check
__all__ = [
- "health_check",
+ "generate_bug_report",
"get_instance_info",
"get_schema",
+ "health_check",
]
diff --git a/superset/mcp_service/system/tool/generate_bug_report.py b/superset/mcp_service/system/tool/generate_bug_report.py
new file mode 100644
index 00000000000..04359a88e95
--- /dev/null
+++ b/superset/mcp_service/system/tool/generate_bug_report.py
@@ -0,0 +1,95 @@
+# 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.
+
+"""Generate bug report tool for MCP service."""
+
+import datetime
+import logging
+import platform
+
+from fastmcp import Context
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.extensions import event_logger
+from superset.mcp_service.system.schemas import (
+ GenerateBugReportRequest,
+ GenerateBugReportResponse,
+)
+from superset.utils.version import get_version_metadata
+
+logger = logging.getLogger(__name__)
+
+
+@tool(
+ tags=["core"],
+ annotations=ToolAnnotations(
+ title="Generate bug report",
+ readOnlyHint=True,
+ destructiveHint=False,
+ ),
+)
+async def generate_bug_report(
+ request: GenerateBugReportRequest, ctx: Context
+) -> GenerateBugReportResponse:
+ """
+ Generate a formatted bug report for the Superset MCP service.
+
+ Produces a structured bug report including system information,
+ user-provided details, and optionally an mcp_call_id for
+ server-side log correlation.
+ """
+ await ctx.info("Generating bug report: summary=%s" % (request.summary,))
+
+ with event_logger.log_context(action="mcp.generate_bug_report"):
+ version_metadata = get_version_metadata()
+ version = version_metadata.get("version_string", "unknown")
+
+ sections = [
+ "## Bug Report",
+ "",
+ f"**Date**: {datetime.datetime.now(tz=datetime.timezone.utc).isoformat()}",
+ f"**Superset Version**: {version}",
+ f"**Python Version**: {platform.python_version()}",
+ f"**Platform**: {platform.system()}",
+ ]
+
+ if request.mcp_call_id:
+ sections.append(f"**MCP Call ID**: {request.mcp_call_id}")
+
+ sections.extend(
+ [
+ "",
+ "### Summary",
+ request.summary,
+ "",
+ "### Steps to Reproduce",
+ request.steps_to_reproduce,
+ "",
+ "### Expected Behavior",
+ request.expected_behavior,
+ "",
+ "### Actual Behavior",
+ request.actual_behavior,
+ ]
+ )
+
+ report = "\n".join(sections)
+
+ return GenerateBugReportResponse(
+ report=report,
+ mcp_call_id=request.mcp_call_id,
+ )
diff --git a/tests/unit_tests/mcp_service/system/tool/test_generate_bug_report.py b/tests/unit_tests/mcp_service/system/tool/test_generate_bug_report.py
new file mode 100644
index 00000000000..a71695f4b5f
--- /dev/null
+++ b/tests/unit_tests/mcp_service/system/tool/test_generate_bug_report.py
@@ -0,0 +1,128 @@
+# 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 generate_bug_report tool."""
+
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+from pydantic import ValidationError
+
+from superset.mcp_service.app import mcp
+from superset.mcp_service.system.schemas import GenerateBugReportRequest
+from superset.utils import json
+
+
+@pytest.fixture
+def mcp_server():
+ return mcp
+
+
+@pytest.fixture(autouse=True)
+def mock_auth():
+ """Mock authentication for client-based tool tests."""
+ with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
+ mock_user = Mock()
+ mock_user.id = 1
+ mock_user.username = "admin"
+ mock_get_user.return_value = mock_user
+ yield mock_get_user
+
+
+_BASE_REQUEST = {
+ "summary": "Test bug",
+ "steps_to_reproduce": "1. Do X",
+ "expected_behavior": "Y should happen",
+ "actual_behavior": "Z happened",
+}
+
+
+@pytest.mark.asyncio
+async def test_generates_report_with_mcp_call_id(mcp_server):
+ """Bug report includes mcp_call_id when provided."""
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "generate_bug_report",
+ {
+ "request": {
+ **_BASE_REQUEST,
+ "mcp_call_id": "abc123def456",
+ }
+ },
+ )
+ data = json.loads(result.content[0].text)
+
+ assert "abc123def456" in data["report"]
+ assert "MCP Call ID" in data["report"]
+ assert data["mcp_call_id"] == "abc123def456"
+
+
+@pytest.mark.asyncio
+async def test_generates_report_without_mcp_call_id(mcp_server):
+ """Bug report omits MCP Call ID line when not provided."""
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "generate_bug_report",
+ {"request": _BASE_REQUEST},
+ )
+ data = json.loads(result.content[0].text)
+
+ assert "MCP Call ID" not in data["report"]
+ assert data["mcp_call_id"] is None
+
+
+@pytest.mark.asyncio
+async def test_report_contains_system_info(mcp_server):
+ """Bug report includes system metadata."""
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "generate_bug_report",
+ {"request": _BASE_REQUEST},
+ )
+ data = json.loads(result.content[0].text)
+
+ report = data["report"]
+ assert "## Bug Report" in report
+ assert "Superset Version" in report
+ assert "Python Version" in report
+ assert "Platform" in report
+ assert "### Summary" in report
+ assert "Test bug" in report
+
+
+def test_schema_rejects_extra_fields():
+ """GenerateBugReportRequest forbids extra fields."""
+ with pytest.raises(ValidationError, match="unknown_field"):
+ GenerateBugReportRequest(
+ summary="bug",
+ steps_to_reproduce="steps",
+ expected_behavior="expected",
+ actual_behavior="actual",
+ unknown_field="value",
+ )
+
+
+def test_schema_mcp_call_id_optional():
+ """mcp_call_id defaults to None."""
+ req = GenerateBugReportRequest(
+ summary="bug",
+ steps_to_reproduce="steps",
+ expected_behavior="expected",
+ actual_behavior="actual",
+ )
+ assert req.mcp_call_id is None
diff --git a/tests/unit_tests/mcp_service/test_middleware_logging.py b/tests/unit_tests/mcp_service/test_middleware_logging.py
index 50d23449707..1da6f7d4575 100644
--- a/tests/unit_tests/mcp_service/test_middleware_logging.py
+++ b/tests/unit_tests/mcp_service/test_middleware_logging.py
@@ -130,6 +130,68 @@ class TestLoggingMiddlewareOnCallTool:
assert call_kwargs["curated_payload"]["tool"] == "get_chart_info"
assert call_kwargs["duration_ms"] >= 0
+ @patch("superset.mcp_service.middleware.event_logger")
+ @patch("superset.mcp_service.middleware.get_user_id", return_value=42)
+ @pytest.mark.asyncio
+ async def test_on_call_tool_includes_mcp_call_id_in_curated_payload(
+ self, mock_get_user_id, mock_event_logger
+ ):
+ """on_call_tool adds mcp_call_id to curated_payload."""
+ middleware = LoggingMiddleware()
+ ctx = _make_context(name="list_charts")
+ call_next = AsyncMock(return_value="tool_result")
+
+ await middleware.on_call_tool(ctx, call_next)
+
+ call_kwargs = mock_event_logger.log.call_args[1]
+ mcp_call_id = call_kwargs["curated_payload"]["mcp_call_id"]
+ assert isinstance(mcp_call_id, str)
+ assert len(mcp_call_id) == 12
+
+ @patch("superset.mcp_service.middleware.event_logger")
+ @patch("superset.mcp_service.middleware.get_user_id", return_value=42)
+ @pytest.mark.asyncio
+ async def test_on_call_tool_injects_mcp_call_id_into_tool_result_meta(
+ self, mock_get_user_id, mock_event_logger
+ ):
+ """on_call_tool injects mcp_call_id into ToolResult.meta."""
+ from fastmcp.tools.tool import ToolResult
+ from mcp import types as mt
+
+ middleware = LoggingMiddleware()
+ ctx = _make_context(name="list_charts")
+ original_result = ToolResult(content=[mt.TextContent(type="text", text="ok")])
+ call_next = AsyncMock(return_value=original_result)
+
+ result = await middleware.on_call_tool(ctx, call_next)
+
+ assert isinstance(result, ToolResult)
+ assert "mcp_call_id" in result.meta
+ assert len(result.meta["mcp_call_id"]) == 12
+
+ @patch("superset.mcp_service.middleware.event_logger")
+ @patch("superset.mcp_service.middleware.get_user_id", return_value=42)
+ @pytest.mark.asyncio
+ async def test_on_call_tool_preserves_existing_meta(
+ self, mock_get_user_id, mock_event_logger
+ ):
+ """on_call_tool merges mcp_call_id with existing ToolResult.meta."""
+ from fastmcp.tools.tool import ToolResult
+ from mcp import types as mt
+
+ middleware = LoggingMiddleware()
+ ctx = _make_context(name="list_charts")
+ original_result = ToolResult(
+ content=[mt.TextContent(type="text", text="ok")],
+ meta={"existing_key": "existing_value"},
+ )
+ call_next = AsyncMock(return_value=original_result)
+
+ result = await middleware.on_call_tool(ctx, call_next)
+
+ assert result.meta["existing_key"] == "existing_value"
+ assert "mcp_call_id" in result.meta
+
@patch("superset.mcp_service.middleware.event_logger")
@patch("superset.mcp_service.middleware.get_user_id", return_value=42)
@pytest.mark.asyncio
@@ -297,7 +359,9 @@ class TestIsErrorResponse:
result = await middleware.on_call_tool(ctx, call_next)
- assert result == error_result
+ assert isinstance(result, ToolResult)
+ assert result.content == error_result.content
+ assert "mcp_call_id" in result.meta
mock_event_logger.log.assert_called_once()
call_kwargs = mock_event_logger.log.call_args[1]
assert call_kwargs["curated_payload"]["success"] is False