Skip to content

Commit bc31281

Browse files
committed
pr feedback
1 parent 41821a3 commit bc31281

File tree

3 files changed

+287
-4
lines changed

3 files changed

+287
-4
lines changed

src/mcpcat/modules/exceptions.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,11 +134,14 @@ def filename_for_module(module: str | None, abs_path: str) -> str:
134134
return abs_path
135135

136136
try:
137+
# Convert compiled .pyc files to source .py paths
137138
if abs_path.endswith(".pyc"):
138139
abs_path = abs_path[:-1]
139140

141+
# Extract root package name (e.g., "myapp" from "myapp.views.admin")
140142
base_module = module.split(".", 1)[0]
141143

144+
# Single-module case (no dots): just return the filename
142145
if base_module == module:
143146
return os.path.basename(abs_path)
144147

@@ -149,7 +152,11 @@ def filename_for_module(module: str | None, abs_path: str) -> str:
149152
if not base_module_file:
150153
return abs_path
151154

155+
# Navigate up 2 levels from package's __init__.py to find project root
156+
# e.g., /project/myapp/__init__.py → rsplit by separator twice → /project
152157
base_module_dir = base_module_file.rsplit(os.sep, 2)[0]
158+
159+
# Extract the path relative to the project root
153160
if abs_path.startswith(base_module_dir):
154161
return abs_path.split(base_module_dir, 1)[-1].lstrip(os.sep)
155162

tests/test_exceptions.py

Lines changed: 249 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
"""Tests for exception tracking functionality."""
22

33
import os
4-
import sys
54
import tempfile
6-
from typing import Any
5+
import time
6+
from unittest.mock import MagicMock
77

88
import pytest
99

10+
from mcpcat import MCPCatOptions, track
11+
from mcpcat.modules.event_queue import EventQueue, set_event_queue
1012
from mcpcat.modules.exceptions import (
1113
capture_exception,
1214
extract_context_line,
@@ -15,9 +17,11 @@
1517
is_in_app,
1618
parse_python_traceback,
1719
stringify_non_exception,
18-
unwrap_exception_chain,
1920
)
2021

22+
from .test_utils.client import create_test_client
23+
from .test_utils.todo_server import create_todo_server
24+
2125

2226
class TestBasicExceptionCapture:
2327
"""Tests for basic exception capture functionality."""
@@ -422,5 +426,247 @@ def test_capture_preserves_all_fields(self):
422426
assert "stack" in error_data
423427

424428

429+
class TestExceptionIntegration:
430+
"""Integration tests for exception capture with real MCP server calls."""
431+
432+
@pytest.fixture(autouse=True)
433+
def setup_and_teardown(self):
434+
"""Set up and tear down for each test."""
435+
from mcpcat.modules.event_queue import event_queue as original_queue
436+
437+
yield
438+
set_event_queue(original_queue)
439+
440+
def _create_mock_event_capture(self):
441+
"""Helper to create mock API client and event capture list."""
442+
mock_api_client = MagicMock()
443+
captured_events = []
444+
445+
def capture_event(publish_event_request):
446+
captured_events.append(publish_event_request)
447+
448+
mock_api_client.publish_event = MagicMock(side_effect=capture_event)
449+
450+
test_queue = EventQueue(api_client=mock_api_client)
451+
set_event_queue(test_queue)
452+
453+
return captured_events
454+
455+
@pytest.mark.asyncio
456+
async def test_tool_raises_value_error(self):
457+
"""Test that ValueError from tools is properly captured."""
458+
captured_events = self._create_mock_event_capture()
459+
460+
server = create_todo_server()
461+
options = MCPCatOptions(enable_tracing=True)
462+
track(server, "test_project", options)
463+
464+
async with create_test_client(server) as client:
465+
await client.call_tool("tool_that_raises", {"error_type": "value"})
466+
time.sleep(1.0)
467+
468+
tool_events = [
469+
e
470+
for e in captured_events
471+
if e.event_type == "mcp:tools/call"
472+
and e.resource_name == "tool_that_raises"
473+
]
474+
assert len(tool_events) > 0, "No tool_that_raises event captured"
475+
476+
event = tool_events[0]
477+
assert event.is_error is True
478+
assert event.error is not None
479+
# MCP SDK wraps tool exceptions - check the message contains the original error
480+
assert "Test value error from tool" in event.error["message"]
481+
482+
@pytest.mark.asyncio
483+
async def test_tool_raises_runtime_error(self):
484+
"""Test that RuntimeError from tools is properly captured."""
485+
captured_events = self._create_mock_event_capture()
486+
487+
server = create_todo_server()
488+
options = MCPCatOptions(enable_tracing=True)
489+
track(server, "test_project", options)
490+
491+
async with create_test_client(server) as client:
492+
await client.call_tool("tool_that_raises", {"error_type": "runtime"})
493+
time.sleep(1.0)
494+
495+
tool_events = [
496+
e
497+
for e in captured_events
498+
if e.event_type == "mcp:tools/call"
499+
and e.resource_name == "tool_that_raises"
500+
]
501+
assert len(tool_events) > 0
502+
503+
event = tool_events[0]
504+
assert event.is_error is True
505+
assert event.error is not None
506+
# MCP SDK wraps tool exceptions - check the message contains the original error
507+
assert "Test runtime error from tool" in event.error["message"]
508+
509+
@pytest.mark.asyncio
510+
async def test_tool_raises_custom_error(self):
511+
"""Test that custom exception types are properly captured."""
512+
captured_events = self._create_mock_event_capture()
513+
514+
server = create_todo_server()
515+
options = MCPCatOptions(enable_tracing=True)
516+
track(server, "test_project", options)
517+
518+
async with create_test_client(server) as client:
519+
await client.call_tool("tool_that_raises", {"error_type": "custom"})
520+
time.sleep(1.0)
521+
522+
tool_events = [
523+
e
524+
for e in captured_events
525+
if e.event_type == "mcp:tools/call"
526+
and e.resource_name == "tool_that_raises"
527+
]
528+
assert len(tool_events) > 0
529+
530+
event = tool_events[0]
531+
assert event.is_error is True
532+
assert event.error is not None
533+
# MCP SDK wraps tool exceptions - check the message contains the original error
534+
assert "Test custom error from tool" in event.error["message"]
535+
536+
@pytest.mark.asyncio
537+
async def test_tool_raises_captures_stack_frames(self):
538+
"""Test that stack frames are properly captured with correct structure."""
539+
captured_events = self._create_mock_event_capture()
540+
541+
server = create_todo_server()
542+
options = MCPCatOptions(enable_tracing=True)
543+
track(server, "test_project", options)
544+
545+
async with create_test_client(server) as client:
546+
await client.call_tool("tool_that_raises", {"error_type": "value"})
547+
time.sleep(1.0)
548+
549+
tool_events = [
550+
e
551+
for e in captured_events
552+
if e.event_type == "mcp:tools/call"
553+
and e.resource_name == "tool_that_raises"
554+
]
555+
assert len(tool_events) > 0
556+
557+
event = tool_events[0]
558+
assert event.error is not None
559+
560+
# Verify frames are captured
561+
frames = event.error.get("frames", [])
562+
assert len(frames) > 0, "No stack frames captured"
563+
564+
# Verify frame structure
565+
for frame in frames:
566+
assert "filename" in frame
567+
assert "abs_path" in frame
568+
assert "function" in frame
569+
assert "module" in frame
570+
assert "lineno" in frame
571+
assert "in_app" in frame
572+
assert isinstance(frame["lineno"], int)
573+
assert isinstance(frame["in_app"], bool)
574+
575+
@pytest.mark.asyncio
576+
async def test_tool_raises_has_in_app_frames(self):
577+
"""Test that stack frames include in_app detection."""
578+
captured_events = self._create_mock_event_capture()
579+
580+
server = create_todo_server()
581+
options = MCPCatOptions(enable_tracing=True)
582+
track(server, "test_project", options)
583+
584+
async with create_test_client(server) as client:
585+
await client.call_tool("tool_that_raises", {"error_type": "value"})
586+
time.sleep(1.0)
587+
588+
tool_events = [
589+
e
590+
for e in captured_events
591+
if e.event_type == "mcp:tools/call"
592+
and e.resource_name == "tool_that_raises"
593+
]
594+
assert len(tool_events) > 0
595+
596+
event = tool_events[0]
597+
frames = event.error.get("frames", [])
598+
assert len(frames) > 0, "Should have stack frames"
599+
600+
# All frames should have in_app field
601+
for frame in frames:
602+
assert "in_app" in frame, "Frame should have in_app field"
603+
assert isinstance(frame["in_app"], bool)
604+
605+
# Verify we have a mix of in_app and not in_app (sdk code is not in_app)
606+
# Note: MCP SDK wraps the error, so the original tool function may not appear
607+
# but we still verify the in_app detection logic works
608+
609+
@pytest.mark.asyncio
610+
async def test_tool_raises_captures_context_lines(self):
611+
"""Test that context lines are captured for in_app frames."""
612+
captured_events = self._create_mock_event_capture()
613+
614+
server = create_todo_server()
615+
options = MCPCatOptions(enable_tracing=True)
616+
track(server, "test_project", options)
617+
618+
async with create_test_client(server) as client:
619+
await client.call_tool("tool_that_raises", {"error_type": "value"})
620+
time.sleep(1.0)
621+
622+
tool_events = [
623+
e
624+
for e in captured_events
625+
if e.event_type == "mcp:tools/call"
626+
and e.resource_name == "tool_that_raises"
627+
]
628+
assert len(tool_events) > 0
629+
630+
event = tool_events[0]
631+
frames = event.error.get("frames", [])
632+
in_app_frames = [f for f in frames if f.get("in_app") is True]
633+
634+
# In-app frames should have context_line
635+
frames_with_context = [f for f in in_app_frames if f.get("context_line")]
636+
assert len(frames_with_context) > 0, "No context lines for in_app frames"
637+
638+
# Context line should contain actual code
639+
for frame in frames_with_context:
640+
context = frame["context_line"]
641+
assert len(context) > 0
642+
assert context.strip() != ""
643+
644+
@pytest.mark.asyncio
645+
async def test_mcp_protocol_error(self):
646+
"""Test that MCP protocol errors (McpError) are properly handled."""
647+
captured_events = self._create_mock_event_capture()
648+
649+
server = create_todo_server()
650+
options = MCPCatOptions(enable_tracing=True)
651+
track(server, "test_project", options)
652+
653+
async with create_test_client(server) as client:
654+
await client.call_tool("tool_with_mcp_error", {})
655+
time.sleep(1.0)
656+
657+
tool_events = [
658+
e
659+
for e in captured_events
660+
if e.event_type == "mcp:tools/call"
661+
and e.resource_name == "tool_with_mcp_error"
662+
]
663+
assert len(tool_events) > 0, "No tool_with_mcp_error event captured"
664+
665+
event = tool_events[0]
666+
assert event.is_error is True
667+
assert event.error is not None
668+
assert "Invalid parameters" in event.error["message"]
669+
670+
425671
if __name__ == "__main__":
426672
pytest.main([__file__, "-v"])

tests/test_utils/todo_server.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Todo server implementation for testing."""
22

3-
from mcp.server import Server
3+
from mcp.shared.exceptions import McpError
4+
from mcp.types import ErrorData
45

56
try:
67
from mcp.server import FastMCP
@@ -11,6 +12,16 @@
1112
HAS_FASTMCP = False
1213

1314

15+
# Standard JSON-RPC error codes
16+
INVALID_PARAMS = -32602
17+
18+
19+
class CustomTestError(Exception):
20+
"""Custom exception type for testing exception capture."""
21+
22+
pass
23+
24+
1425
class Todo:
1526
"""Todo item."""
1627

@@ -64,11 +75,30 @@ def complete_todo(id: int) -> str:
6475

6576
raise ValueError(f"Todo with ID {id} not found")
6677

78+
@server.tool()
79+
def tool_that_raises(error_type: str = "value") -> str:
80+
"""A tool that raises Python exceptions for testing."""
81+
if error_type == "value":
82+
raise ValueError("Test value error from tool")
83+
elif error_type == "runtime":
84+
raise RuntimeError("Test runtime error from tool")
85+
elif error_type == "custom":
86+
raise CustomTestError("Test custom error from tool")
87+
return "Should not reach here"
88+
89+
@server.tool()
90+
def tool_with_mcp_error() -> str:
91+
"""A tool that returns an MCP protocol error."""
92+
error = ErrorData(code=INVALID_PARAMS, message="Invalid parameters")
93+
raise McpError(error)
94+
6795
# Store original handlers for testing
6896
server._original_handlers = {
6997
"add_todo": add_todo,
7098
"list_todos": list_todos,
7199
"complete_todo": complete_todo,
100+
"tool_that_raises": tool_that_raises,
101+
"tool_with_mcp_error": tool_with_mcp_error,
72102
}
73103

74104
return server

0 commit comments

Comments
 (0)