|
| 1 | +""" |
| 2 | +Bug report tool for Home Assistant MCP Server. |
| 3 | +
|
| 4 | +This module provides a tool to collect diagnostic information and guide users |
| 5 | +on how to create effective bug reports. |
| 6 | +""" |
| 7 | + |
| 8 | +import logging |
| 9 | +from typing import Annotated, Any |
| 10 | + |
| 11 | +from pydantic import Field |
| 12 | + |
| 13 | +from ha_mcp import __version__ |
| 14 | + |
| 15 | +from ..utils.usage_logger import AVG_LOG_ENTRIES_PER_TOOL, get_recent_logs |
| 16 | +from .helpers import log_tool_usage |
| 17 | + |
| 18 | +logger = logging.getLogger(__name__) |
| 19 | + |
| 20 | + |
| 21 | +def register_bug_report_tools(mcp: Any, client: Any, **kwargs: Any) -> None: |
| 22 | + """Register bug report tools with the MCP server.""" |
| 23 | + |
| 24 | + @mcp.tool( |
| 25 | + annotations={ |
| 26 | + "idempotentHint": True, |
| 27 | + "readOnlyHint": True, |
| 28 | + "tags": ["system", "diagnostics"], |
| 29 | + "title": "Bug Report Info", |
| 30 | + } |
| 31 | + ) |
| 32 | + @log_tool_usage |
| 33 | + async def ha_bug_report( |
| 34 | + tool_call_count: Annotated[ |
| 35 | + int, |
| 36 | + Field( |
| 37 | + default=3, |
| 38 | + ge=1, |
| 39 | + le=50, |
| 40 | + description=( |
| 41 | + "Number of tool calls made since the bug started. " |
| 42 | + "This determines how many log entries to include. " |
| 43 | + "The AI agent should count how many ha_* tools it called " |
| 44 | + "from when the issue began. Default: 3" |
| 45 | + ), |
| 46 | + ), |
| 47 | + ] = 3, |
| 48 | + ) -> dict[str, Any]: |
| 49 | + """ |
| 50 | + Collect diagnostic information for filing bug reports against ha-mcp. |
| 51 | +
|
| 52 | + **WHEN TO USE THIS TOOL:** |
| 53 | + Use this tool when the user says something like: |
| 54 | + - "I want to file a bug for: <reason>" |
| 55 | + - "This isn't working, I need to report this" |
| 56 | + - "How do I report this issue?" |
| 57 | +
|
| 58 | + **BEFORE CALLING THIS TOOL:** |
| 59 | + If the bug details are unclear, guide the user to provide: |
| 60 | + 1. What they were trying to do (the goal) |
| 61 | + 2. What actually happened (the result) |
| 62 | + 3. What they expected to happen instead |
| 63 | + 4. Any error messages they saw |
| 64 | + 5. Steps to reproduce the issue |
| 65 | +
|
| 66 | + **PARAMETERS:** |
| 67 | + - tool_call_count: Count how many ha_* tools you called since the bug started. |
| 68 | + This helps include the right amount of logs. Default is 3. |
| 69 | +
|
| 70 | + **OUTPUT:** |
| 71 | + Returns diagnostic info, recent logs, and a bug report template. |
| 72 | + The template guides privacy-conscious reporting while preserving |
| 73 | + enough detail to diagnose the issue. |
| 74 | + """ |
| 75 | + diagnostic_info: dict[str, Any] = { |
| 76 | + "ha_mcp_version": __version__, |
| 77 | + "connection_status": "Unknown", |
| 78 | + "home_assistant_version": "Unknown", |
| 79 | + "entity_count": 0, |
| 80 | + } |
| 81 | + |
| 82 | + # Try to get Home Assistant config and connection status |
| 83 | + try: |
| 84 | + config = await client.get_config() |
| 85 | + diagnostic_info["connection_status"] = "Connected" |
| 86 | + diagnostic_info["home_assistant_version"] = config.get( |
| 87 | + "version", "Unknown" |
| 88 | + ) |
| 89 | + diagnostic_info["location_name"] = config.get("location_name", "Unknown") |
| 90 | + diagnostic_info["time_zone"] = config.get("time_zone", "Unknown") |
| 91 | + except Exception as e: |
| 92 | + logger.warning(f"Failed to get Home Assistant config: {e}") |
| 93 | + diagnostic_info["connection_status"] = f"Connection Error: {str(e)}" |
| 94 | + |
| 95 | + # Try to get entity count |
| 96 | + try: |
| 97 | + states = await client.get_states() |
| 98 | + if states: |
| 99 | + diagnostic_info["entity_count"] = len(states) |
| 100 | + except Exception as e: |
| 101 | + logger.warning(f"Failed to get entity count: {e}") |
| 102 | + |
| 103 | + # Calculate how many log entries to retrieve |
| 104 | + # Formula: AVG_LOG_ENTRIES_PER_TOOL * 2 * tool_call_count |
| 105 | + max_log_entries = AVG_LOG_ENTRIES_PER_TOOL * 2 * tool_call_count |
| 106 | + recent_logs = get_recent_logs(max_entries=max_log_entries) |
| 107 | + |
| 108 | + # Format logs for inclusion (sanitized summary) |
| 109 | + log_summary = _format_logs_for_report(recent_logs) |
| 110 | + |
| 111 | + # Build the formatted report |
| 112 | + report_lines = [ |
| 113 | + "=== ha-mcp Bug Report Info ===", |
| 114 | + "", |
| 115 | + f"Home Assistant Version: {diagnostic_info['home_assistant_version']}", |
| 116 | + f"ha-mcp Version: {diagnostic_info['ha_mcp_version']}", |
| 117 | + f"Connection Status: {diagnostic_info['connection_status']}", |
| 118 | + f"Entity Count: {diagnostic_info['entity_count']}", |
| 119 | + ] |
| 120 | + |
| 121 | + # Add optional fields if available |
| 122 | + if "location_name" in diagnostic_info: |
| 123 | + report_lines.append(f"Location Name: {diagnostic_info['location_name']}") |
| 124 | + if "time_zone" in diagnostic_info: |
| 125 | + report_lines.append(f"Time Zone: {diagnostic_info['time_zone']}") |
| 126 | + |
| 127 | + if recent_logs: |
| 128 | + report_lines.extend([ |
| 129 | + "", |
| 130 | + f"=== Recent Tool Calls ({len(recent_logs)} entries) ===", |
| 131 | + log_summary, |
| 132 | + ]) |
| 133 | + |
| 134 | + formatted_report = "\n".join(report_lines) |
| 135 | + |
| 136 | + # Bug report template for the AI to present to the user |
| 137 | + bug_report_template = _generate_bug_report_template( |
| 138 | + diagnostic_info, log_summary |
| 139 | + ) |
| 140 | + |
| 141 | + # Anonymization instructions |
| 142 | + anonymization_guide = _generate_anonymization_guide() |
| 143 | + |
| 144 | + return { |
| 145 | + "success": True, |
| 146 | + "diagnostic_info": diagnostic_info, |
| 147 | + "recent_logs": recent_logs, |
| 148 | + "log_count": len(recent_logs), |
| 149 | + "formatted_report": formatted_report, |
| 150 | + "bug_report_template": bug_report_template, |
| 151 | + "anonymization_guide": anonymization_guide, |
| 152 | + "issue_url": "https://github.com/homeassistant-ai/ha-mcp/issues/new", |
| 153 | + "instructions": ( |
| 154 | + "Present the bug_report_template to the user. " |
| 155 | + "Ask them to review and fill in the [DESCRIBE...] sections. " |
| 156 | + "Remind them to follow the anonymization_guide to protect their privacy. " |
| 157 | + "The user should copy the completed template and submit it at the issue_url." |
| 158 | + ), |
| 159 | + } |
| 160 | + |
| 161 | + |
| 162 | +def _format_logs_for_report(logs: list[dict[str, Any]]) -> str: |
| 163 | + """Format log entries for inclusion in a bug report.""" |
| 164 | + if not logs: |
| 165 | + return "(No recent logs available)" |
| 166 | + |
| 167 | + lines = [] |
| 168 | + for log in logs: |
| 169 | + timestamp = log.get("timestamp", "?")[:19] # Trim to seconds |
| 170 | + tool_name = log.get("tool_name", "unknown") |
| 171 | + success = "OK" if log.get("success") else "FAIL" |
| 172 | + exec_time = log.get("execution_time_ms", 0) |
| 173 | + error = log.get("error_message", "") |
| 174 | + |
| 175 | + line = f" {timestamp} | {tool_name} | {success} | {exec_time:.0f}ms" |
| 176 | + if error: |
| 177 | + # Truncate error to avoid leaking sensitive info |
| 178 | + error_short = str(error)[:100] |
| 179 | + line += f" | Error: {error_short}" |
| 180 | + lines.append(line) |
| 181 | + |
| 182 | + return "\n".join(lines) |
| 183 | + |
| 184 | + |
| 185 | +def _generate_bug_report_template( |
| 186 | + diagnostic_info: dict[str, Any], log_summary: str |
| 187 | +) -> str: |
| 188 | + """Generate a bug report template for users to fill out.""" |
| 189 | + return f"""## Bug Report Template |
| 190 | +
|
| 191 | +**Copy this template, fill in the sections, and submit at:** |
| 192 | +https://github.com/homeassistant-ai/ha-mcp/issues/new |
| 193 | +
|
| 194 | +--- |
| 195 | +
|
| 196 | +### I want to file a bug for: [DESCRIBE THE BUG IN ONE SENTENCE] |
| 197 | +
|
| 198 | +### What I was trying to do |
| 199 | +[DESCRIBE YOUR GOAL - What were you asking the AI to do?] |
| 200 | +
|
| 201 | +### What happened |
| 202 | +[DESCRIBE THE RESULT - What did the AI do or say?] |
| 203 | +
|
| 204 | +### What I expected |
| 205 | +[DESCRIBE EXPECTED BEHAVIOR - What should have happened instead?] |
| 206 | +
|
| 207 | +### Steps to reproduce |
| 208 | +1. [First step] |
| 209 | +2. [Second step] |
| 210 | +3. [...] |
| 211 | +
|
| 212 | +### Error messages (if any) |
| 213 | +``` |
| 214 | +[PASTE ANY ERROR MESSAGES HERE] |
| 215 | +``` |
| 216 | +
|
| 217 | +### Environment |
| 218 | +- Home Assistant Version: {diagnostic_info.get('home_assistant_version', 'Unknown')} |
| 219 | +- ha-mcp Version: {diagnostic_info.get('ha_mcp_version', 'Unknown')} |
| 220 | +- Connection Status: {diagnostic_info.get('connection_status', 'Unknown')} |
| 221 | +- Entity Count: {diagnostic_info.get('entity_count', 0)} |
| 222 | +- Time Zone: {diagnostic_info.get('time_zone', 'Unknown')} |
| 223 | +
|
| 224 | +### Recent tool calls |
| 225 | +``` |
| 226 | +{log_summary} |
| 227 | +``` |
| 228 | +
|
| 229 | +### Additional context |
| 230 | +[ADD ANY OTHER RELEVANT INFORMATION] |
| 231 | +
|
| 232 | +--- |
| 233 | +**Privacy note:** Please review and anonymize any sensitive information before submitting. |
| 234 | +See the anonymization guide in the tool output for details. |
| 235 | +""" |
| 236 | + |
| 237 | + |
| 238 | +def _generate_anonymization_guide() -> str: |
| 239 | + """Generate privacy/anonymization instructions.""" |
| 240 | + return """## Anonymization Guide |
| 241 | +
|
| 242 | +Before submitting your bug report, please review and anonymize: |
| 243 | +
|
| 244 | +### MUST ANONYMIZE (security-sensitive): |
| 245 | +- API tokens, passwords, secrets -> Replace with "[REDACTED]" |
| 246 | +- IP addresses (internal/external) -> Replace with "192.168.x.x" or "[IP]" |
| 247 | +- MAC addresses -> Replace with "[MAC]" |
| 248 | +- Email addresses -> Replace with "user@example.com" |
| 249 | +- Phone numbers -> Replace with "[PHONE]" |
| 250 | +
|
| 251 | +### CONSIDER ANONYMIZING (privacy-sensitive): |
| 252 | +- Location names (city, address) -> Replace with generic names like "Home" or "[LOCATION]" |
| 253 | +- Device names that reveal personal info -> Replace with "Device 1", "Light 1", etc. |
| 254 | +- Person names in entity IDs -> Replace with "person.user1" |
| 255 | +- Calendar/todo items with personal details -> Summarize without specifics |
| 256 | +
|
| 257 | +### KEEP AS-IS (helpful for debugging): |
| 258 | +- Entity domains (light, switch, sensor, etc.) |
| 259 | +- Device types and capabilities |
| 260 | +- Automation/script structure (triggers, conditions, actions) |
| 261 | +- Error messages (but check for secrets in them) |
| 262 | +- Timestamps and durations |
| 263 | +- State values (on/off, numeric values, etc.) |
| 264 | +- Home Assistant and ha-mcp versions |
| 265 | +
|
| 266 | +### Example anonymization: |
| 267 | +BEFORE: "light.juliens_bedroom" with token "eyJhbG..." |
| 268 | +AFTER: "light.bedroom_1" with token "[REDACTED]" |
| 269 | +
|
| 270 | +The goal is to preserve enough detail to reproduce and fix the bug |
| 271 | +while protecting your personal information and security. |
| 272 | +""" |
0 commit comments