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