Skip to content

Commit ff9dc30

Browse files
rodion-mclaude
andcommitted
Raise ToolError instead of returning error strings (isError: true)
All tool errors now raise ToolError (from fastmcp.exceptions) instead of returning JSON/XML error strings. This sets isError: true in the MCP response, letting agents distinguish errors from data and make better retry/recovery decisions. Changes: - handle_api_error() raises ToolError instead of returning a string - All validation errors (empty query, invalid max_results, etc.) raise ToolError instead of returning JSON - fetch_artifacts validation raises ToolError instead of returning XML - 38 tests updated to expect ToolError via pytest.raises() - E2E tests now verify result.is_error flag alongside error text Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4b80a62 commit ff9dc30

12 files changed

Lines changed: 211 additions & 271 deletions

src/tests/test_artifact_relationships.py

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from unittest.mock import AsyncMock, MagicMock, patch
77

88
from fastmcp import Context
9+
from fastmcp.exceptions import ToolError
910

1011
from tools.artifact_relationships import (
1112
PROFILE_MAP,
@@ -284,22 +285,18 @@ async def test_explicit_profile_maps_correctly(self, mock_get_api_key):
284285
assert call_args[1]["json"]["profile"] == "InheritanceOnly"
285286

286287
@pytest.mark.asyncio
287-
async def test_empty_identifier_returns_error(self):
288+
async def test_empty_identifier_raises_tool_error(self):
288289
ctx = MagicMock(spec=Context)
289-
result = await get_artifact_relationships(ctx=ctx, identifier="")
290-
data = json.loads(result)
291-
assert "error" in data
292-
assert "required" in data["error"]
290+
with pytest.raises(ToolError, match="required"):
291+
await get_artifact_relationships(ctx=ctx, identifier="")
293292

294293
@pytest.mark.asyncio
295-
async def test_unsupported_profile_returns_error(self):
294+
async def test_unsupported_profile_raises_tool_error(self):
296295
ctx = MagicMock(spec=Context)
297-
result = await get_artifact_relationships(
298-
ctx=ctx, identifier="id", profile="invalidProfile"
299-
)
300-
data = json.loads(result)
301-
assert "error" in data
302-
assert "Unsupported profile" in data["error"]
296+
with pytest.raises(ToolError, match="Unsupported profile"):
297+
await get_artifact_relationships(
298+
ctx=ctx, identifier="id", profile="invalidProfile"
299+
)
303300

304301
@pytest.mark.asyncio
305302
@patch("tools.artifact_relationships.get_api_key_from_context")
@@ -327,11 +324,8 @@ async def test_api_error_returns_error_json(self, mock_get_api_key):
327324
mock_context.base_url = "https://app.codealive.ai"
328325
ctx.request_context.lifespan_context = mock_context
329326

330-
result = await get_artifact_relationships(ctx=ctx, identifier="id")
331-
332-
data = json.loads(result)
333-
assert "error" in data
334-
assert "401" in data["error"]
327+
with pytest.raises(ToolError, match="401"):
328+
await get_artifact_relationships(ctx=ctx, identifier="id")
335329

336330
@pytest.mark.asyncio
337331
@patch("tools.artifact_relationships.get_api_key_from_context")

src/tests/test_chat_tool.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from unittest.mock import AsyncMock, MagicMock, patch
55
import json
66
from fastmcp import Context
7+
from fastmcp.exceptions import ToolError
78
from tools.chat import chat, codebase_consultant
89

910

@@ -162,12 +163,12 @@ async def test_chat_empty_question_validation(mock_get_api_key):
162163
ctx.request_context.lifespan_context = MagicMock()
163164

164165
# Test with empty question
165-
result = await chat(ctx=ctx, question="")
166-
assert "Error: No question provided" in result
166+
with pytest.raises(ToolError, match="No question provided"):
167+
await chat(ctx=ctx, question="")
167168

168169
# Test with whitespace only
169-
result = await chat(ctx=ctx, question=" ")
170-
assert "Error: No question provided" in result
170+
with pytest.raises(ToolError, match="No question provided"):
171+
await chat(ctx=ctx, question=" ")
171172

172173

173174

@@ -176,9 +177,9 @@ async def test_chat_empty_question_validation(mock_get_api_key):
176177
@patch('tools.chat.get_api_key_from_context')
177178
@patch('tools.chat.handle_api_error')
178179
async def test_chat_error_handling(mock_handle_error, mock_get_api_key):
179-
"""Test error handling in chat."""
180+
"""Test error handling in chat — handle_api_error raises ToolError."""
180181
mock_get_api_key.return_value = "test_key"
181-
mock_handle_error.return_value = "Error: Authentication failed"
182+
mock_handle_error.side_effect = ToolError("Error: Authentication failed")
182183

183184
ctx = MagicMock(spec=Context)
184185
ctx.info = AsyncMock()
@@ -192,11 +193,11 @@ async def test_chat_error_handling(mock_handle_error, mock_get_api_key):
192193

193194
ctx.request_context.lifespan_context = mock_codealive_context
194195

195-
result = await chat(
196-
ctx=ctx,
197-
question="Test",
198-
data_sources=["repo123"]
199-
)
196+
with pytest.raises(ToolError, match="Authentication failed"):
197+
await chat(
198+
ctx=ctx,
199+
question="Test",
200+
data_sources=["repo123"]
201+
)
200202

201-
assert result == "Error: Authentication failed"
202203
mock_handle_error.assert_called_once()

src/tests/test_e2e_tools.py

Lines changed: 29 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,8 @@ async def test_backend_500_returns_error(self):
159159
result = await client.call_tool("get_data_sources", {}, raise_on_error=False)
160160

161161
text = _text(result)
162-
data = json.loads(text)
163-
assert "error" in data
164-
assert "500" in data["error"] or "Server error" in data["error"]
162+
assert result.is_error
163+
assert "500" in text or "Server error" in text
165164

166165

167166
# ---------------------------------------------------------------------------
@@ -218,9 +217,8 @@ async def test_empty_query_returns_error(self):
218217
)
219218

220219
text = _text(result)
221-
data = json.loads(text)
222-
assert "error" in data
223-
assert "empty" in data["error"].lower()
220+
assert result.is_error
221+
assert "empty" in text.lower() or "Query cannot be empty" in text
224222

225223
@pytest.mark.asyncio
226224
async def test_no_results_returns_empty_json(self):
@@ -265,9 +263,8 @@ async def test_404_returns_not_found_error(self):
265263
)
266264

267265
text = _text(result)
268-
data = json.loads(text)
269-
assert "error" in data
270-
assert "404" in data["error"] or "not found" in data["error"].lower()
266+
assert result.is_error
267+
assert "404" in text or "not found" in text.lower()
271268

272269

273270
# ---------------------------------------------------------------------------
@@ -354,9 +351,8 @@ async def test_max_results_boundary_0_rejected(self):
354351
{"query": "test", "data_sources": ["repo"], "max_results": 0},
355352
raise_on_error=False,
356353
)
357-
data = json.loads(_text(result))
358-
assert "error" in data
359-
assert "max_results" in data["error"]
354+
assert result.is_error
355+
assert "max_results" in _text(result)
360356

361357
@pytest.mark.asyncio
362358
async def test_max_results_boundary_501_rejected(self):
@@ -367,9 +363,8 @@ async def test_max_results_boundary_501_rejected(self):
367363
{"query": "test", "data_sources": ["repo"], "max_results": 501},
368364
raise_on_error=False,
369365
)
370-
data = json.loads(_text(result))
371-
assert "error" in data
372-
assert "max_results" in data["error"]
366+
assert result.is_error
367+
assert "max_results" in _text(result)
373368

374369
@pytest.mark.asyncio
375370
async def test_max_results_boundary_500_accepted(self):
@@ -496,9 +491,9 @@ async def test_404_includes_recovery_hint(self):
496491
{"query": "test", "data_sources": ["bad-repo"]},
497492
raise_on_error=False,
498493
)
499-
data = json.loads(_text(result))
500-
assert "error" in data
501-
assert "get_data_sources" in data["error"]
494+
text = _text(result)
495+
assert result.is_error
496+
assert "get_data_sources" in text
502497

503498

504499
# ---------------------------------------------------------------------------
@@ -597,8 +592,8 @@ async def test_max_results_boundary_0_rejected(self):
597592
{"query": "test", "data_sources": ["repo"], "max_results": 0},
598593
raise_on_error=False,
599594
)
600-
data = json.loads(_text(result))
601-
assert "error" in data
595+
assert result.is_error
596+
assert "max_results" in _text(result)
602597

603598
@pytest.mark.asyncio
604599
async def test_max_results_boundary_501_rejected(self):
@@ -609,8 +604,8 @@ async def test_max_results_boundary_501_rejected(self):
609604
{"query": "test", "data_sources": ["repo"], "max_results": 501},
610605
raise_on_error=False,
611606
)
612-
data = json.loads(_text(result))
613-
assert "error" in data
607+
assert result.is_error
608+
assert "max_results" in _text(result)
614609

615610
@pytest.mark.asyncio
616611
async def test_extensions_forwarded(self):
@@ -672,9 +667,8 @@ async def test_empty_query_returns_error(self):
672667
{"query": "", "data_sources": ["repo"]},
673668
raise_on_error=False,
674669
)
675-
data = json.loads(_text(result))
676-
assert "error" in data
677-
assert "empty" in data["error"].lower()
670+
assert result.is_error
671+
assert "empty" in _text(result).lower() or "Query cannot be empty" in _text(result)
678672

679673
@pytest.mark.asyncio
680674
async def test_data_sources_as_string_normalized(self):
@@ -700,9 +694,8 @@ async def test_404_includes_recovery_hint(self):
700694
{"query": "test", "data_sources": ["bad-repo"]},
701695
raise_on_error=False,
702696
)
703-
data = json.loads(_text(result))
704-
assert "error" in data
705-
assert "get_data_sources" in data["error"]
697+
assert result.is_error
698+
assert "get_data_sources" in _text(result)
706699

707700

708701
# ---------------------------------------------------------------------------
@@ -1098,9 +1091,8 @@ async def test_empty_identifier_returns_error(self):
10981091
raise_on_error=False,
10991092
)
11001093

1101-
data = json.loads(_text(result))
1102-
assert "error" in data
1103-
assert "required" in data["error"].lower()
1094+
assert result.is_error
1095+
assert "required" in _text(result).lower()
11041096

11051097
@pytest.mark.asyncio
11061098
async def test_max_count_per_type_0_rejected(self):
@@ -1111,9 +1103,8 @@ async def test_max_count_per_type_0_rejected(self):
11111103
{"identifier": "org/repo::x", "max_count_per_type": 0},
11121104
raise_on_error=False,
11131105
)
1114-
data = json.loads(_text(result))
1115-
assert "error" in data
1116-
assert "max_count_per_type" in data["error"]
1106+
assert result.is_error
1107+
assert "max_count_per_type" in _text(result)
11171108

11181109
@pytest.mark.asyncio
11191110
async def test_max_count_per_type_1001_rejected(self):
@@ -1124,9 +1115,8 @@ async def test_max_count_per_type_1001_rejected(self):
11241115
{"identifier": "org/repo::x", "max_count_per_type": 1001},
11251116
raise_on_error=False,
11261117
)
1127-
data = json.loads(_text(result))
1128-
assert "error" in data
1129-
assert "max_count_per_type" in data["error"]
1118+
assert result.is_error
1119+
assert "max_count_per_type" in _text(result)
11301120

11311121
@pytest.mark.asyncio
11321122
async def test_max_count_per_type_negative_rejected(self):
@@ -1137,9 +1127,8 @@ async def test_max_count_per_type_negative_rejected(self):
11371127
{"identifier": "org/repo::x", "max_count_per_type": -1},
11381128
raise_on_error=False,
11391129
)
1140-
data = json.loads(_text(result))
1141-
assert "error" in data
1142-
assert "max_count_per_type" in data["error"]
1130+
assert result.is_error
1131+
assert "max_count_per_type" in _text(result)
11431132

11441133
@pytest.mark.asyncio
11451134
async def test_max_count_per_type_forwarded(self):

0 commit comments

Comments
 (0)