From 885ed06aa4360aaee5442c64222cde40bac8d0fa Mon Sep 17 00:00:00 2001 From: Vivek Kumar Singh Date: Sun, 11 Oct 2026 11:52:51 +0530 Subject: [PATCH] fix(sensor): attribute MCP servers and failed tool calls in Claude Code sessions --- Sensor/README.md | 5 + Sensor/adr_sensor/parsers/claude_parser.py | 59 +++++- Sensor/tests/test_claude_parser.py | 216 +++++++++++++++++++++ 3 files changed, 272 insertions(+), 8 deletions(-) diff --git a/Sensor/README.md b/Sensor/README.md index 73824e7..274246b 100644 --- a/Sensor/README.md +++ b/Sensor/README.md @@ -50,6 +50,11 @@ apply: top-level tool argument strings and tool results are truncated at 1,000 characters; non-text content blocks and separately spilled tool-output files are not imported. Contract tests use synthetic transcripts and do not launch Claude. +Tools are classified by execution type: `Bash` shell commands emit +`tool_type: "terminal_command"`, while MCP tools namespaced as `mcp____` +record `tool_type: "mcp_tool"` with `server_name` populated. Failed and blocked +tool calls (`is_error: true`) set `status: "error"` and retain the error output. + ### Claude Desktop Agent Mode The `claude_desktop` source covers Claude Desktop's local agent mode (released as diff --git a/Sensor/adr_sensor/parsers/claude_parser.py b/Sensor/adr_sensor/parsers/claude_parser.py index 8fd6472..b7afe5b 100644 --- a/Sensor/adr_sensor/parsers/claude_parser.py +++ b/Sensor/adr_sensor/parsers/claude_parser.py @@ -299,6 +299,25 @@ def parse_jsonl_file(self, file_path: Path) -> List[AgentEvent]: return entries + @staticmethod + def _classify_tool(tool_name: str) -> Tuple[str, Optional[str]]: + """Classify a Claude Code tool into tool_type and optional server_name. + + Claude Code names tools as: + - 'Bash': Terminal command execution ('terminal_command') + - 'mcp____': MCP server tool invocation ('mcp_tool') + - Others (e.g. Read, Edit, Glob, Grep, View): Built-in tool usage ('tool_use') + """ + if not isinstance(tool_name, str): + return "tool_use", None + if tool_name in ("Bash", "bash"): + return "terminal_command", None + if tool_name.startswith("mcp__"): + parts = tool_name.split("__", 2) + if len(parts) >= 2 and parts[1]: + return "mcp_tool", parts[1] + return "tool_use", None + def _extract_message_data(self, obj: Dict[str, Any]) -> Optional[Dict[str, Any]]: """Extract only the needed data from a message object.""" msg_type = obj.get("type") @@ -336,14 +355,30 @@ def _extract_message_data(self, obj: Dict[str, Any]) -> Optional[Dict[str, Any]] self.record_diagnostic("record_shape_error") continue result_content = item.get("content", "") - if "toolUseResult" in obj and isinstance(obj["toolUseResult"], dict): - result_content = obj["toolUseResult"].get("result", result_content) + tur = obj.get("toolUseResult") if isinstance(obj.get("toolUseResult"), dict) else {} + if tur: + result_content = tur.get("result", result_content) + if not result_content and "error" in tur: + result_content = tur.get("error", "") result_content = self._normalize_result_content(result_content) if result_content and isinstance(result_content, str): result_content = truncate_middle(result_content, max_length=1000, edge_chars=400) - tool_results.append({"tool_use_id": tool_use_id, "result": result_content}) + + is_error = bool( + item.get("is_error") is True + or item.get("is_error") == "true" + or tur.get("is_error") is True + or tur.get("is_error") == "true" + or tur.get("status") == "error" + or "error" in tur + ) + tool_results.append({ + "tool_use_id": tool_use_id, + "result": result_content, + "is_error": is_error, + }) extracted["tool_results"] = tool_results @@ -354,7 +389,7 @@ def _extract_message_data(self, obj: Dict[str, Any]) -> Optional[Dict[str, Any]] if not isinstance(item, dict) or item.get("type") != "tool_use": continue raw_input = item.get("input", {}) - name = item.get("name", "unknown") + name = item.get("name") or "unknown" if not isinstance(raw_input, dict) or not isinstance(name, str): self.record_diagnostic("record_shape_error") continue @@ -391,14 +426,19 @@ def _create_entry_from_extracted_session( for tool_result in tool_results: tool_use_id = tool_result.get("tool_use_id") result = tool_result.get("result") + is_error = tool_result.get("is_error", False) if tool_use_id in pending_tools: message_index, tool_index = pending_tools[tool_use_id] old_message = chat_history[message_index] + old_tool = old_message.tools[tool_index] new_tools = list(old_message.tools) + status = "error" if is_error else "success" + error = result if is_error and result else None new_tools[tool_index] = replace( - new_tools[tool_index], + old_tool, result=result, - status="success" if result else "unknown", + status=status, + error=error, ) chat_history[message_index] = replace(old_message, tools=new_tools) @@ -412,9 +452,12 @@ def _create_entry_from_extracted_session( tools = [] for tool_data in msg_data.get("tools", []): + tool_name = tool_data.get("name") or "unknown" + tool_type, server_name = self._classify_tool(tool_name) tool = ToolUsage( - tool_name=tool_data.get("name", "unknown"), - tool_type="tool_use", + tool_name=tool_name, + tool_type=tool_type, + server_name=server_name, arguments=tool_data.get("input", {}), result=None, ) diff --git a/Sensor/tests/test_claude_parser.py b/Sensor/tests/test_claude_parser.py index 571d7f8..63993ec 100644 --- a/Sensor/tests/test_claude_parser.py +++ b/Sensor/tests/test_claude_parser.py @@ -376,3 +376,219 @@ def test_incomplete_records_are_never_joined_across_physical_lines(tmp_path): assert _contents(entries) == ["Independent complete message"] assert entries[0].session_context["event_count"] == 1 + + +@pytest.mark.parametrize( + ("tool_name", "expected_type", "expected_server"), + [ + ("Bash", "terminal_command", None), + ("bash", "terminal_command", None), + ("mcp__github__create_issue", "mcp_tool", "github"), + ("mcp__postgres_db__query_rows", "mcp_tool", "postgres_db"), + ("mcp__fetch__fetch", "mcp_tool", "fetch"), + ("mcp__server", "mcp_tool", "server"), + ("mcp__", "tool_use", None), + ("mcp____tool", "tool_use", None), + ("Read", "tool_use", None), + ("Edit", "tool_use", None), + ("Glob", "tool_use", None), + ("Grep", "tool_use", None), + ("unknown_tool", "tool_use", None), + (None, "tool_use", None), + ], +) +def test_classify_tool_direct(tool_name, expected_type, expected_server): + tool_type, server_name = ClaudeParser._classify_tool(tool_name) + assert tool_type == expected_type + assert server_name == expected_server + + +def test_tool_classification_in_parsed_session(tmp_path): + path = _write_records( + tmp_path / "session.jsonl", + [ + _record("user", "Run multiple operations"), + _record( + "assistant", + [ + {"type": "tool_use", "id": "call_1", "name": "Bash", "input": {"command": "ls -la"}}, + { + "type": "tool_use", + "id": "call_2", + "name": "mcp__github__create_issue", + "input": {"title": "bug"}, + }, + {"type": "tool_use", "id": "call_3", "name": "Edit", "input": {"file_path": "foo.py"}}, + ], + ), + ], + ) + + entries = ClaudeParser().parse_jsonl_file(path) + assert len(entries) == 1 + assistant_msg = entries[0].chat_history[1] + assert len(assistant_msg.tools) == 3 + + assert assistant_msg.tools[0].tool_name == "Bash" + assert assistant_msg.tools[0].tool_type == "terminal_command" + assert assistant_msg.tools[0].server_name is None + + assert assistant_msg.tools[1].tool_name == "mcp__github__create_issue" + assert assistant_msg.tools[1].tool_type == "mcp_tool" + assert assistant_msg.tools[1].server_name == "github" + + assert assistant_msg.tools[2].tool_name == "Edit" + assert assistant_msg.tools[2].tool_type == "tool_use" + assert assistant_msg.tools[2].server_name is None + + +def test_server_name_and_tool_type_survive_tool_result_merge(tmp_path): + path = _write_records( + tmp_path / "session.jsonl", + [ + _record("user", "Fetch website content"), + _record( + "assistant", + [ + { + "type": "tool_use", + "id": "fetch_1", + "name": "mcp__fetch__fetch_url", + "input": {"url": "https://uber.com"}, + } + ], + ), + _record( + "user", + [{"type": "tool_result", "tool_use_id": "fetch_1", "content": "Uber"}], + ), + ], + ) + + entries = ClaudeParser().parse_jsonl_file(path) + assert len(entries) == 1 + assistant_msg = entries[0].chat_history[1] + assert len(assistant_msg.tools) == 1 + + tool = assistant_msg.tools[0] + assert tool.tool_name == "mcp__fetch__fetch_url" + assert tool.tool_type == "mcp_tool" + assert tool.server_name == "fetch" + assert tool.status == "success" + assert tool.result == "Uber" + assert tool.error is None + + +def test_is_error_flag_marks_status_error_and_populates_error_field(tmp_path): + path = _write_records( + tmp_path / "session.jsonl", + [ + _record("user", "Attempt unauthorized command"), + _record( + "assistant", + [{"type": "tool_use", "id": "bash_1", "name": "Bash", "input": {"command": "cat /etc/shadow"}}], + ), + _record( + "user", + [ + { + "type": "tool_result", + "tool_use_id": "bash_1", + "content": "Permission denied", + "is_error": True, + } + ], + ), + ], + ) + + entries = ClaudeParser().parse_jsonl_file(path) + assert len(entries) == 1 + assistant_msg = entries[0].chat_history[1] + tool = assistant_msg.tools[0] + + assert tool.tool_name == "Bash" + assert tool.tool_type == "terminal_command" + assert tool.status == "error" + assert tool.error == "Permission denied" + assert tool.result == "Permission denied" + + +def test_tool_use_result_envelope_error_and_fallback(tmp_path): + path = _write_records( + tmp_path / "session.jsonl", + [ + _record("user", "Query MCP server"), + _record( + "assistant", + [{"type": "tool_use", "id": "db_1", "name": "mcp__db__query", "input": {"sql": "SELECT 1"}}], + ), + _record( + "user", + [{"type": "tool_result", "tool_use_id": "db_1", "content": ""}], + toolUseResult={"error": "Connection refused to database", "is_error": True}, + ), + ], + ) + + entries = ClaudeParser().parse_jsonl_file(path) + assert len(entries) == 1 + assistant_msg = entries[0].chat_history[1] + tool = assistant_msg.tools[0] + + assert tool.tool_name == "mcp__db__query" + assert tool.tool_type == "mcp_tool" + assert tool.server_name == "db" + assert tool.status == "error" + assert tool.error == "Connection refused to database" + assert tool.result == "Connection refused to database" + + +def test_empty_result_without_error_has_success_status(tmp_path): + path = _write_records( + tmp_path / "session.jsonl", + [ + _record("user", "Touch file"), + _record( + "assistant", + [{"type": "tool_use", "id": "touch_1", "name": "Bash", "input": {"command": "touch empty.txt"}}], + ), + _record( + "user", + [{"type": "tool_result", "tool_use_id": "touch_1", "content": ""}], + ), + ], + ) + + entries = ClaudeParser().parse_jsonl_file(path) + assert len(entries) == 1 + assistant_msg = entries[0].chat_history[1] + tool = assistant_msg.tools[0] + + assert tool.tool_type == "terminal_command" + assert tool.status == "success" + assert tool.result == "" + assert tool.error is None + + +def test_null_or_absent_tool_name_does_not_drop_session(tmp_path): + path = _write_records( + tmp_path / "session.jsonl", + [ + _record("user", "Run tool with null name"), + _record( + "assistant", + [{"type": "tool_use", "id": "call_null", "name": None, "input": {}}], + ), + ], + ) + + entries = ClaudeParser().parse_jsonl_file(path) + assert len(entries) == 1 + assistant_msg = entries[0].chat_history[1] + assert len(assistant_msg.tools) == 1 + tool = assistant_msg.tools[0] + assert tool.tool_name == "unknown" + assert tool.tool_type == "tool_use" + assert tool.server_name is None +