You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by en...@apache.org on 2026/04/23 15:01:56 UTC
(superset) 01/01: feat(mcp): add generate_bug_report tool with PII sanitization
This is an automated email from the ASF dual-hosted git repository.
EnxDev pushed a commit to branch enxdev/feature/mcp-add-generate-bug-report-tool
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 540af5a71c4476f9b91217c4e2b57f125f4b1bfe
Author: Enzo Martellucci <en...@gmail.com>
AuthorDate: Thu Apr 23 16:55:44 2026 +0200
feat(mcp): add generate_bug_report tool with PII sanitization
---
superset/mcp_service/app.py | 3 +
superset/mcp_service/system/schemas.py | 81 +++++-
superset/mcp_service/system/tool/__init__.py | 2 +
.../mcp_service/system/tool/generate_bug_report.py | 288 +++++++++++++++++++++
.../system/tool/test_generate_bug_report.py | 252 ++++++++++++++++++
5 files changed, 619 insertions(+), 7 deletions(-)
diff --git a/superset/mcp_service/app.py b/superset/mcp_service/app.py
index 74ef1c91954..fb8eac92e33 100644
--- a/superset/mcp_service/app.py
+++ b/superset/mcp_service/app.py
@@ -85,6 +85,8 @@ Schema Discovery:
System Information:
- get_instance_info: Get instance-wide statistics, metadata, and current user identity
- health_check: Simple health check tool (takes NO parameters, call without arguments)
+- generate_bug_report: Build a PII-sanitized bug report to send to Preset support
+ (use when the user says the MCP is broken or asks how to report an issue)
Available Resources:
- instance://metadata: Instance configuration, stats, and available dataset IDs
@@ -513,6 +515,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/system/schemas.py b/superset/mcp_service/system/schemas.py
index ae0fab91e62..d9cca6d3563 100644
--- a/superset/mcp_service/system/schemas.py
+++ b/superset/mcp_service/system/schemas.py
@@ -25,7 +25,7 @@ system-level info.
from __future__ import annotations
from datetime import datetime
-from typing import Any, Dict, List
+from typing import Any
from pydantic import BaseModel, ConfigDict, Field
@@ -86,12 +86,12 @@ class DashboardBreakdown(BaseModel):
class DatabaseBreakdown(BaseModel):
- by_type: Dict[str, int]
+ by_type: dict[str, int]
class PopularContent(BaseModel):
- top_tags: List[str] = Field(default_factory=list)
- top_creators: List[str] = Field(default_factory=list)
+ top_tags: list[str] = Field(default_factory=list)
+ top_creators: list[str] = Field(default_factory=list)
class FeatureAvailability(BaseModel):
@@ -101,7 +101,7 @@ class FeatureAvailability(BaseModel):
so they reflect the actual permissions of the requesting user.
"""
- accessible_menus: List[str] = Field(
+ accessible_menus: list[str] = Field(
default_factory=list,
description=(
"UI menu items accessible to the current user, "
@@ -133,7 +133,7 @@ class UserInfo(BaseModel):
last_name: str | None = None
email: str | None = None
active: bool | None = None
- roles: List[str] = Field(
+ roles: list[str] = Field(
default_factory=list,
description=(
"Role names assigned to the user (e.g., Admin, Alpha, Gamma, Viewer). "
@@ -175,7 +175,7 @@ class TagInfo(BaseModel):
class RoleInfo(BaseModel):
id: int | None = None
name: str | None = None
- permissions: List[str] | None = None
+ permissions: list[str] | None = None
class PaginationInfo(BaseModel):
@@ -186,3 +186,70 @@ class PaginationInfo(BaseModel):
has_next: bool
has_previous: bool
model_config = ConfigDict(ser_json_timedelta="iso8601")
+
+
+class GenerateBugReportRequest(BaseModel):
+ """Request schema for the generate_bug_report tool.
+
+ All fields are optional so users can invoke the tool even when they only
+ remember part of what happened. The handler performs PII sanitization on
+ free-text fields before building the final report.
+ """
+
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
+
+ tool_name: str | None = Field(
+ None,
+ description=(
+ "The MCP tool the user was using when the issue occurred "
+ "(e.g. 'generate_chart', 'execute_sql')."
+ ),
+ )
+ error_message: str | None = Field(
+ None,
+ description=(
+ "The error message or unexpected behavior the user encountered. "
+ "Emails, IPs, tokens and similar secrets are automatically redacted."
+ ),
+ )
+ llm_used: str | None = Field(
+ None,
+ description=(
+ "The LLM / client the user was running when the issue occurred "
+ "(e.g. 'Claude Sonnet 4.6', 'ChatGPT', 'Cursor + GPT-4')."
+ ),
+ )
+ steps_to_reproduce: str | None = Field(
+ None,
+ description="Optional free-text description of what the user was trying to do.",
+ )
+ additional_context: str | None = Field(
+ None,
+ description=(
+ "Any other information the user wants to include. "
+ "PII and secrets are sanitized before being written to the report."
+ ),
+ )
+
+
+class GenerateBugReportResponse(BaseModel):
+ """Response schema for the generate_bug_report tool.
+
+ ``report`` is a pre-formatted, copy-paste-friendly markdown block that the
+ user can send to the Preset support team. ``redactions_applied`` lists the
+ categories of PII/secret that were stripped from the user's free-text input
+ so the user can confirm nothing important was lost.
+ """
+
+ report: str = Field(..., description="Pre-formatted, PII-sanitized bug report.")
+ redactions_applied: list[str] = Field(
+ default_factory=list,
+ description=(
+ "Categories of sensitive data that were redacted from user-provided "
+ "free-text fields (e.g. 'email', 'ip_address', 'token')."
+ ),
+ )
+ support_contact: str = Field(
+ ...,
+ description="Where the user should send the report.",
+ )
diff --git a/superset/mcp_service/system/tool/__init__.py b/superset/mcp_service/system/tool/__init__.py
index eb1dfb88013..f55f3d37ee9 100644
--- a/superset/mcp_service/system/tool/__init__.py
+++ b/superset/mcp_service/system/tool/__init__.py
@@ -17,11 +17,13 @@
"""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__ = [
+ "generate_bug_report",
"health_check",
"get_instance_info",
"get_schema",
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..075d145a023
--- /dev/null
+++ b/superset/mcp_service/system/tool/generate_bug_report.py
@@ -0,0 +1,288 @@
+# 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 a copy-pasteable bug report for the Preset support team.
+
+The tool collects a minimal, safe snapshot of the MCP service environment and
+combines it with user-supplied context (tool that failed, error seen, LLM /
+client in use, free-text notes). Free-text fields are sanitized so emails,
+IP addresses, tokens, bearer auth headers, credentialed URLs and similar
+secrets never make it into the final report.
+"""
+
+from __future__ import annotations
+
+import datetime
+import logging
+import platform
+import re
+from typing import Any, Callable, Union
+
+import flask
+from flask import current_app
+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__)
+
+SUPPORT_CONTACT = "Preset support team (support@preset.io)"
+
+_EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
+_IPV4_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
+# IPv6: two forms, both require signals that distinguish them from timestamps
+# like "12:34:56" (3 numeric groups, 2 colons) which the naive pattern matches.
+# 1. "::" compression — real IPv6 shorthand, e.g. ::1, fe80::1, 2001:db8::1
+# 2. Full-ish form: at least 4 colon-separated groups (3+ colons) AND at
+# least one group containing a hex letter, e.g. fe80:0:0:0:1:2:3:4. This
+# trades coverage of the rare all-numeric IPv6 (e.g. 2001:0:0:0:0:0:0:1)
+# for not shredding every stack trace that contains a timestamp.
+_IPV6_RE = re.compile(
+ r"(?:"
+ # "::" compression, optionally with groups on either side. The trailing
+ # group list greedily consumes any remaining "(:hex){1,6}:hex" so we
+ # don't leave orphan ":370:7334"-style residue in the redacted output.
+ r"\b(?:[0-9a-fA-F]{1,4}:){1,7}:(?:[0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4})*)?"
+ r"|::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}\b"
+ r"|\b(?=[0-9a-fA-F:]*[a-fA-F])" # must have a hex letter somewhere
+ r"(?:[0-9a-fA-F]{1,4}:){3,7}[0-9a-fA-F]{1,4}\b"
+ r")"
+)
+_BEARER_RE = re.compile(r"(?i)\b(bearer|token|api[_-]?key)\s+[A-Za-z0-9._\-]+")
+_KEY_VALUE_SECRET_RE = re.compile(
+ r"(?i)\b(password|passwd|pwd|secret|api[_-]?key|access[_-]?key|"
+ r"auth[_-]?token|authorization|bearer|session[_-]?id)"
+ r"(\s*[:=]\s*)\"?([^\"\s,;]+)\"?"
+)
+_URL_CREDENTIALS_RE = re.compile(r"(\b\w+://)[^\s/@]+:[^\s/@]+@")
+_LONG_HEX_RE = re.compile(r"\b[A-Fa-f0-9]{32,}\b")
+_JWT_RE = re.compile(r"\beyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\b")
+
+_DEFAULT_BUG_REPORT_REQUEST = GenerateBugReportRequest()
+
+
+def _sanitize_text(text: str, redactions: set[str]) -> str:
+ """Redact common PII / secret patterns from free-text input.
+
+ Tracks every category that actually matched in ``redactions`` so the
+ caller can surface that list to the user.
+ """
+ if not text:
+ return text
+
+ def _sub(
+ pattern: re.Pattern[str],
+ replacement: Union[str, Callable[[re.Match[str]], str]],
+ category: str,
+ value: str,
+ ) -> str:
+ new_value, count = pattern.subn(replacement, value)
+ if count:
+ redactions.add(category)
+ return new_value
+
+ # Order matters: strip JWTs / credentialed URLs before generic hex/email
+ # patterns get a chance to partially match their substrings.
+ text = _sub(_JWT_RE, "[REDACTED_JWT]", "jwt", text)
+ text = _sub(
+ _URL_CREDENTIALS_RE, r"\1[REDACTED_CREDENTIALS]@", "url_credentials", text
+ )
+ # _BEARER_RE must run BEFORE _KEY_VALUE_SECRET_RE: both cover the
+ # "bearer" keyword and the replacement "Bearer [REDACTED_TOKEN]" contains
+ # no ':' / '=' separator, so the kv regex can't re-match it. Reordering
+ # would leak the secret through the less-specific pattern.
+ text = _sub(_BEARER_RE, r"\1 [REDACTED_TOKEN]", "token", text)
+ text = _sub(
+ _KEY_VALUE_SECRET_RE,
+ lambda m: f"{m.group(1)}{m.group(2)}[REDACTED_SECRET]",
+ "secret",
+ text,
+ )
+ text = _sub(_EMAIL_RE, "[REDACTED_EMAIL]", "email", text)
+ text = _sub(_IPV6_RE, "[REDACTED_IP]", "ip_address", text)
+ text = _sub(_IPV4_RE, "[REDACTED_IP]", "ip_address", text)
+ text = _sub(_LONG_HEX_RE, "[REDACTED_HEX]", "long_hex_token", text)
+ return text
+
+
+def _safe_str(value: Any) -> str:
+ try:
+ return str(value)
+ except Exception: # noqa: BLE001 — fallback, never fail a bug report
+ return "<unavailable>"
+
+
+def _collect_environment() -> dict[str, str]:
+ """Collect non-sensitive environment metadata for the report."""
+ env: dict[str, str] = {
+ "python_version": platform.python_version(),
+ "platform": platform.platform(),
+ "superset_version": "unknown",
+ "service": "Superset MCP Service",
+ }
+
+ try:
+ version_metadata = get_version_metadata()
+ env["superset_version"] = _safe_str(
+ version_metadata.get("version_string", "unknown")
+ )
+ except Exception: # noqa: BLE001
+ logger.warning("bug_report: unable to read Superset version", exc_info=True)
+
+ try:
+ app_name = current_app.config.get("APP_NAME", "Superset")
+ env["service"] = f"{app_name} MCP Service"
+ except Exception: # noqa: BLE001
+ # current_app may be unavailable outside a Flask context
+ logger.debug("bug_report: no Flask app context for APP_NAME", exc_info=True)
+
+ return env
+
+
+def _collect_user_context() -> dict[str, Any]:
+ """Collect a minimal, PII-free user context.
+
+ Only the numeric user id and role names are included — usernames, emails,
+ and full names are intentionally omitted.
+ """
+ ctx: dict[str, Any] = {"user_id": None, "roles": []}
+ try:
+ user = getattr(flask.g, "user", None)
+ except Exception: # noqa: BLE001
+ user = None
+
+ if user is None:
+ return ctx
+
+ ctx["user_id"] = getattr(user, "id", None)
+ raw_roles = getattr(user, "roles", None) or []
+ try:
+ ctx["roles"] = [r.name for r in raw_roles if hasattr(r, "name")]
+ except TypeError:
+ ctx["roles"] = []
+ return ctx
+
+
+def _format_report(
+ request: GenerateBugReportRequest,
+ sanitized: dict[str, str | None],
+ environment: dict[str, str],
+ user_context: dict[str, Any],
+ timestamp: str,
+) -> str:
+ """Render the final markdown report."""
+ lines: list[str] = [
+ "# Superset MCP Bug Report",
+ "",
+ f"- **Timestamp (UTC):** {timestamp}",
+ f"- **Service:** {environment['service']}",
+ f"- **Superset version:** {environment['superset_version']}",
+ f"- **Python version:** {environment['python_version']}",
+ f"- **Platform:** {environment['platform']}",
+ f"- **User ID:** {user_context['user_id']}",
+ f"- **Roles:** {', '.join(user_context['roles']) or 'none'}",
+ "",
+ "## What the user was doing",
+ f"- **MCP tool:** {request.tool_name or 'not provided'}",
+ f"- **LLM / client:** {request.llm_used or 'not provided'}",
+ "",
+ "## Error / unexpected behavior",
+ sanitized.get("error_message") or "_not provided_",
+ "",
+ "## Steps to reproduce",
+ sanitized.get("steps_to_reproduce") or "_not provided_",
+ "",
+ "## Additional context",
+ sanitized.get("additional_context") or "_not provided_",
+ "",
+ "---",
+ (
+ "_This report was generated by the Superset MCP service. "
+ "Emails, IPs, tokens, credentialed URLs and other common "
+ "secrets are redacted automatically — please double-check "
+ "before sending._"
+ ),
+ ]
+ return "\n".join(lines)
+
+
+@tool(
+ tags=["core"],
+ protect=False,
+ annotations=ToolAnnotations(
+ title="Generate bug report",
+ readOnlyHint=True,
+ destructiveHint=False,
+ ),
+)
+async def generate_bug_report(
+ request: GenerateBugReportRequest = _DEFAULT_BUG_REPORT_REQUEST,
+) -> GenerateBugReportResponse:
+ """Generate a copy-pasteable bug report for the Preset support team.
+
+ Use this tool when something goes wrong with the MCP service and the
+ user wants to report it. The tool collects a safe snapshot of the
+ environment, combines it with the context the user provides (tool
+ that failed, error seen, LLM / client in use, optional free-text
+ notes) and returns a markdown report that the user can paste into
+ their support ticket.
+
+ PII and secrets are redacted from free-text fields before they are
+ written to the report (emails, IP addresses, bearer tokens, API keys,
+ credentialed URLs, JWTs, long hex blobs). The response lists every
+ category that was actually redacted so the user can spot-check.
+
+ All request fields are optional — the tool still produces a useful
+ report when the user only remembers part of what happened.
+ """
+ with event_logger.log_context(action="mcp.generate_bug_report"):
+ redactions: set[str] = set()
+ sanitized = {
+ "error_message": _sanitize_text(request.error_message or "", redactions)
+ or None,
+ "steps_to_reproduce": _sanitize_text(
+ request.steps_to_reproduce or "", redactions
+ )
+ or None,
+ "additional_context": _sanitize_text(
+ request.additional_context or "", redactions
+ )
+ or None,
+ }
+
+ environment = _collect_environment()
+ user_context = _collect_user_context()
+ timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat()
+
+ report = _format_report(
+ request=request,
+ sanitized=sanitized,
+ environment=environment,
+ user_context=user_context,
+ timestamp=timestamp,
+ )
+
+ return GenerateBugReportResponse(
+ report=report,
+ redactions_applied=sorted(redactions),
+ support_contact=SUPPORT_CONTACT,
+ )
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..15247a6c8d8
--- /dev/null
+++ b/tests/unit_tests/mcp_service/system/tool/test_generate_bug_report.py
@@ -0,0 +1,252 @@
+# 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.
+
+"""Tests for the generate_bug_report MCP tool."""
+
+from unittest.mock import Mock, patch
+
+import pytest
+from fastmcp import Client
+
+from superset.mcp_service.app import mcp
+from superset.mcp_service.system.tool.generate_bug_report import _sanitize_text
+from superset.utils import json
+
+
+@pytest.fixture
+def mcp_server():
+ return mcp
+
+
+# ---------------------------------------------------------------------------
+# Unit tests: _sanitize_text (pure function, no Flask/MCP context required)
+# ---------------------------------------------------------------------------
+
+
+def test_sanitize_text_redacts_email():
+ redactions: set[str] = set()
+ out = _sanitize_text("reach me at alice@example.com please", redactions)
+ assert "alice@example.com" not in out
+ assert "[REDACTED_EMAIL]" in out
+ assert "email" in redactions
+
+
+def test_sanitize_text_does_not_redact_timestamps_as_ipv6():
+ """Regression: '12:34:56' is a time, not an IPv6 address."""
+ redactions: set[str] = set()
+ msg = "failed at 12:34:56 and also 01:02:03:04 during retry"
+ out = _sanitize_text(msg, redactions)
+ assert "12:34:56" in out
+ assert "01:02:03:04" in out
+ assert "ip_address" not in redactions
+
+
+def test_sanitize_text_redacts_real_ipv6_with_compression():
+ redactions: set[str] = set()
+ for addr in ("fe80::1", "::1", "2001:db8::1", "2001:db8:85a3::8a2e:370:7334"):
+ redactions.clear()
+ out = _sanitize_text(f"bound to {addr} before crash", redactions)
+ # Full address consumed — no orphan ":370:7334"-style residue left over.
+ assert out == "bound to [REDACTED_IP] before crash", (addr, out)
+ assert "ip_address" in redactions
+
+
+def test_sanitize_text_redacts_ipv6_with_hex_groups():
+ """Full-form IPv6 with hex letters should redact."""
+ redactions: set[str] = set()
+ out = _sanitize_text("peer fe80:0:0:0:1:2:3:abcd refused", redactions)
+ assert "fe80:0:0:0:1:2:3:abcd" not in out
+ assert "ip_address" in redactions
+
+
+def test_sanitize_text_redacts_ipv4():
+ redactions: set[str] = set()
+ out = _sanitize_text("failed connecting to 10.0.0.42 port 8088", redactions)
+ assert "10.0.0.42" not in out
+ assert "[REDACTED_IP]" in out
+ assert "ip_address" in redactions
+
+
+def test_sanitize_text_redacts_bearer_token():
+ redactions: set[str] = set()
+ out = _sanitize_text(
+ "Authorization header was 'Bearer abc123.def456-ghi'", redactions
+ )
+ assert "abc123.def456-ghi" not in out
+ assert "[REDACTED_TOKEN]" in out
+ assert "token" in redactions
+
+
+def test_sanitize_text_redacts_key_value_secret():
+ redactions: set[str] = set()
+ out = _sanitize_text("connected with password=hunter2 to db", redactions)
+ assert "hunter2" not in out
+ assert "password=[REDACTED_SECRET]" in out
+ assert "secret" in redactions
+
+
+def test_sanitize_text_preserves_original_separator():
+ """password: foo stays 'password: [REDACTED_SECRET]' — don't rewrite to '='."""
+ redactions: set[str] = set()
+ out = _sanitize_text("header was password: hunter2 here", redactions)
+ assert "hunter2" not in out
+ assert "password: [REDACTED_SECRET]" in out
+ assert "=" not in out.split("password", 1)[1].split("[REDACTED_SECRET]", 1)[0]
+
+
+def test_sanitize_text_redacts_credentialed_url():
+ redactions: set[str] = set()
+ out = _sanitize_text(
+ "driver://admin:s3cret@db.internal:5432/prod failed", redactions
+ )
+ assert "admin:s3cret@" not in out
+ assert "[REDACTED_CREDENTIALS]" in out
+ assert "url_credentials" in redactions
+
+
+def test_sanitize_text_redacts_jwt():
+ redactions: set[str] = set()
+ jwt = (
+ "eyJhbGciOiJIUzI1NiJ9."
+ "eyJzdWIiOiIxMjM0NSJ9."
+ "sflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
+ )
+ out = _sanitize_text(f"got token {jwt} on retry", redactions)
+ assert jwt not in out
+ assert "[REDACTED_JWT]" in out
+ assert "jwt" in redactions
+
+
+def test_sanitize_text_redacts_long_hex_blob():
+ redactions: set[str] = set()
+ hex_blob = "a" * 40
+ out = _sanitize_text(f"session id was {hex_blob} before crash", redactions)
+ assert hex_blob not in out
+ assert "[REDACTED_HEX]" in out
+ assert "long_hex_token" in redactions
+
+
+def test_sanitize_text_leaves_safe_text_alone():
+ redactions: set[str] = set()
+ safe = "generate_chart failed with KeyError on column 'revenue'"
+ out = _sanitize_text(safe, redactions)
+ assert out == safe
+ assert redactions == set()
+
+
+def test_sanitize_text_handles_empty():
+ redactions: set[str] = set()
+ assert _sanitize_text("", redactions) == ""
+ assert redactions == set()
+
+
+# ---------------------------------------------------------------------------
+# Tool-level tests: generate_bug_report via MCP Client
+# ---------------------------------------------------------------------------
+
+
+class TestGenerateBugReportViaMCP:
+ """Exercise the tool end-to-end through the MCP client."""
+
+ @pytest.mark.asyncio
+ async def test_generate_bug_report_builds_markdown_and_redacts(self, mcp_server):
+ mock_user = Mock()
+ mock_user.id = 42
+ role = Mock()
+ role.name = "Alpha"
+ mock_user.roles = [role]
+
+ with patch("flask.g") as mock_g:
+ mock_g.user = mock_user
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "generate_bug_report",
+ {
+ "request": {
+ "tool_name": "generate_chart",
+ "error_message": (
+ "Failed talking to 10.0.0.7, "
+ "token was Bearer abc.def-ghi, "
+ "owner alice@example.com"
+ ),
+ "llm_used": "Claude Sonnet 4.6",
+ "steps_to_reproduce": "Ran generate_chart on dataset 5",
+ "additional_context": (
+ "driver://user:pw@db.internal/prod was the target"
+ ),
+ }
+ },
+ )
+
+ data = json.loads(result.content[0].text)
+
+ assert "report" in data
+ assert data["support_contact"].startswith("Preset support team")
+ report = data["report"]
+
+ # Structure
+ assert "# Superset MCP Bug Report" in report
+ assert "MCP tool:** generate_chart" in report
+ assert "LLM / client:** Claude Sonnet 4.6" in report
+ assert "User ID:** 42" in report
+ assert "Roles:** Alpha" in report
+
+ # PII/secrets are gone
+ assert "10.0.0.7" not in report
+ assert "alice@example.com" not in report
+ assert "abc.def-ghi" not in report
+ assert "user:pw@" not in report
+
+ # Redaction categories surfaced to caller
+ redactions = set(data["redactions_applied"])
+ assert {"ip_address", "email", "token", "url_credentials"}.issubset(redactions)
+
+ @pytest.mark.asyncio
+ async def test_generate_bug_report_with_no_user_context(self, mcp_server):
+ """Tool must still produce a report when g.user is unset."""
+ with patch("flask.g") as mock_g:
+ mock_g.user = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "generate_bug_report",
+ {"request": {"tool_name": "execute_sql"}},
+ )
+
+ data = json.loads(result.content[0].text)
+ assert "User ID:** None" in data["report"]
+ assert "Roles:** none" in data["report"]
+ assert data["redactions_applied"] == []
+
+ @pytest.mark.asyncio
+ async def test_generate_bug_report_with_no_args(self, mcp_server):
+ """All fields are optional — empty request still returns a usable report."""
+ with patch("flask.g") as mock_g:
+ mock_g.user = None
+
+ async with Client(mcp_server) as client:
+ result = await client.call_tool(
+ "generate_bug_report",
+ {"request": {}},
+ )
+
+ data = json.loads(result.content[0].text)
+ report = data["report"]
+ assert "MCP tool:** not provided" in report
+ assert "LLM / client:** not provided" in report
+ assert "_not provided_" in report # empty sections