Fix: raise on MCP tool errors instead of returning error text as a string
Browse filesRoot cause of the recurring "'str' object has no attribute 'items'" noise:
when a decoupleR tool raised server-side, MCP returns isError=True with the
message as plain text. _parse_mcp_content ignored isError, tried json.loads
(which fails on a plain message), and handed the agent the error TEXT as a
string. The agent's dict-expecting code then did result.items()/result['k']
on that string and failed with a misleading secondary error that hid the
real cause.
Now _parse_mcp_content checks result.isError and raises RuntimeError with the
message, so the executor reports the actual tool error (e.g. "dataset_id 'foo'
not found") and the agent self-corrects on the real problem. Verified
end-to-end against a raising FastMCP tool and a normal dict tool.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@@ -119,6 +119,13 @@ class MCPManager:
|
|
| 119 |
await session.initialize()
|
| 120 |
result = await session.call_tool(tool_name, kwargs)
|
| 121 |
content = result.content[0] if result.content else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
if content is None:
|
| 123 |
return {}
|
| 124 |
if hasattr(content, "text"):
|
|
@@ -286,15 +293,31 @@ class MCPManager:
|
|
| 286 |
print(f"Failed to discover remote tools from {url}: {e}")
|
| 287 |
return []
|
| 288 |
|
| 289 |
-
def _parse_mcp_content(
|
| 290 |
-
"""Extract
|
| 291 |
|
| 292 |
-
MCP tools return dicts serialized as JSON in content.text
|
| 293 |
-
|
| 294 |
without hitting 'string indices must be integers, not str'.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
"""
|
| 296 |
import json
|
| 297 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
try:
|
| 299 |
return json.loads(text)
|
| 300 |
except (json.JSONDecodeError, TypeError):
|
|
@@ -313,7 +336,7 @@ class MCPManager:
|
|
| 313 |
async with ClientSession(reader, writer) as session:
|
| 314 |
await session.initialize()
|
| 315 |
result = await session.call_tool(tool_name, kwargs)
|
| 316 |
-
return _parse_mcp_content(result
|
| 317 |
|
| 318 |
try:
|
| 319 |
loop = asyncio.get_running_loop()
|
|
@@ -339,7 +362,7 @@ class MCPManager:
|
|
| 339 |
async with ClientSession(read, write) as session:
|
| 340 |
await session.initialize()
|
| 341 |
result = await session.call_tool(tool_name, kwargs)
|
| 342 |
-
return _parse_mcp_content(result
|
| 343 |
|
| 344 |
try:
|
| 345 |
loop = asyncio.get_running_loop()
|
|
|
|
| 119 |
await session.initialize()
|
| 120 |
result = await session.call_tool(tool_name, kwargs)
|
| 121 |
content = result.content[0] if result.content else None
|
| 122 |
+
text = content.text if (content is not None and hasattr(content, "text")) else (
|
| 123 |
+
str(content) if content is not None else "")
|
| 124 |
+
# Surface server-side tool errors as raised exceptions
|
| 125 |
+
# instead of handing the agent the error text as a string
|
| 126 |
+
# (which then breaks dict-expecting code misleadingly).
|
| 127 |
+
if getattr(result, "isError", False):
|
| 128 |
+
raise RuntimeError(text or "tool reported an error")
|
| 129 |
if content is None:
|
| 130 |
return {}
|
| 131 |
if hasattr(content, "text"):
|
|
|
|
| 293 |
print(f"Failed to discover remote tools from {url}: {e}")
|
| 294 |
return []
|
| 295 |
|
| 296 |
+
def _parse_mcp_content(result) -> Any:
|
| 297 |
+
"""Extract the tool result from an MCP CallToolResult.
|
| 298 |
|
| 299 |
+
MCP tools return dicts serialized as JSON in content[0].text;
|
| 300 |
+
returning a parsed Python dict lets agent code do result["key"]
|
| 301 |
without hitting 'string indices must be integers, not str'.
|
| 302 |
+
|
| 303 |
+
If the tool raised server-side, MCP sets result.isError and puts the
|
| 304 |
+
error message in content[0].text. We must NOT hand that text back as a
|
| 305 |
+
plain string: the agent's dict-expecting code then does result.items()
|
| 306 |
+
on it and fails with a misleading "'str' object has no attribute
|
| 307 |
+
'items'" that hides the real cause. Instead we raise, so the executor
|
| 308 |
+
reports the actual tool error and the agent can self-correct on it.
|
| 309 |
"""
|
| 310 |
import json
|
| 311 |
+
|
| 312 |
+
content = result.content[0] if getattr(result, "content", None) else None
|
| 313 |
+
text = content.text if (content is not None and hasattr(content, "text")) else (
|
| 314 |
+
str(content) if content is not None else "")
|
| 315 |
+
|
| 316 |
+
if getattr(result, "isError", False):
|
| 317 |
+
raise RuntimeError(text or "tool reported an error")
|
| 318 |
+
|
| 319 |
+
if content is None:
|
| 320 |
+
return {}
|
| 321 |
try:
|
| 322 |
return json.loads(text)
|
| 323 |
except (json.JSONDecodeError, TypeError):
|
|
|
|
| 336 |
async with ClientSession(reader, writer) as session:
|
| 337 |
await session.initialize()
|
| 338 |
result = await session.call_tool(tool_name, kwargs)
|
| 339 |
+
return _parse_mcp_content(result)
|
| 340 |
|
| 341 |
try:
|
| 342 |
loop = asyncio.get_running_loop()
|
|
|
|
| 362 |
async with ClientSession(read, write) as session:
|
| 363 |
await session.initialize()
|
| 364 |
result = await session.call_tool(tool_name, kwargs)
|
| 365 |
+
return _parse_mcp_content(result)
|
| 366 |
|
| 367 |
try:
|
| 368 |
loop = asyncio.get_running_loop()
|