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