MCP server for tangled

fix: properly extract error messages from atproto exceptions

- fix _extract_error_message to handle e.response.content.message structure
- add tests for error message extraction
- tested against atproto_client exception hierarchy

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Changed files
+50 -4
src
tangled_mcp
_tangled
tests
+7 -4
src/tangled_mcp/_tangled/_client.py
··· 10 10 11 11 def _extract_error_message(e: Exception) -> str: 12 12 """extract a clean, concise error message from an exception""" 13 - # handle atproto Response objects with XrpcError 14 - if hasattr(e, "content") and hasattr(e.content, "message"): 15 - return e.content.message 13 + # handle atproto RequestErrorBase (BadRequestError, UnauthorizedError, etc.) 14 + # structure: e.response.content.message 15 + if hasattr(e, "response") and e.response: 16 + content = getattr(e.response, "content", None) 17 + if content and hasattr(content, "message"): 18 + return content.message 16 19 # handle httpx errors 17 20 if hasattr(e, "response") and hasattr(e.response, "text"): 18 - return e.response.text[:200] # truncate long responses 21 + return e.response.text[:200] 19 22 # fallback to string but limit length 20 23 msg = str(e) 21 24 if len(msg) > 200:
+43
tests/test_resolver.py
··· 2 2 3 3 import pytest 4 4 5 + from tangled_mcp._tangled._client import _extract_error_message 6 + 7 + 8 + class TestExtractErrorMessage: 9 + """test error message extraction from various exception types""" 10 + 11 + def test_extracts_from_atproto_response(self): 12 + """extracts message from atproto RequestErrorBase structure""" 13 + 14 + class MockContent: 15 + message = "handle must be a valid handle" 16 + 17 + class MockResponse: 18 + content = MockContent() 19 + 20 + class MockException(Exception): 21 + response = MockResponse() 22 + 23 + msg = _extract_error_message(MockException()) 24 + assert msg == "handle must be a valid handle" 25 + 26 + def test_truncates_long_messages(self): 27 + """truncates messages longer than 200 chars""" 28 + long_msg = "x" * 300 29 + 30 + class MockException(Exception): 31 + pass 32 + 33 + e = MockException(long_msg) 34 + msg = _extract_error_message(e) 35 + assert len(msg) == 203 # 200 + "..." 36 + assert msg.endswith("...") 37 + 38 + def test_handles_none_response(self): 39 + """handles exception with None response""" 40 + 41 + class MockException(Exception): 42 + response = None 43 + 44 + e = MockException("fallback message") 45 + msg = _extract_error_message(e) 46 + assert msg == "fallback message" 47 + 5 48 6 49 class TestRepoIdentifierParsing: 7 50 """test repository identifier format validation"""