Repository navigation
refactor(narratives): raise typed errors from the MCP tool client - #542
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a structured exception hierarchy for MCP client failures by adding a base McpClientError and a specific McpToolError for tool-layer failures. It refactors async_call_tool to raise McpToolError instead of returning an error dictionary, and updates the workflow loop and tests to handle this new exception-based error reporting. Additionally, it introduces JsonValue and JsonObject type aliases in a new json_value.py module to replace untyped dictionaries for JSON-like payloads. I have no feedback to provide as the changes are clean, well-tested, and align with the repository's style guidelines.
juliawu
left a comment
There was a problem hiding this comment.
Thanks for this! Overall, LGTM!
I think it's fine as is for now, but it looks like the types in json_value would only be used by the MCP client for now. Are you imaginging those types will eventually get shared across the stack? Otherwise, a two-line json_value.py top-level module feels a little premature. We should move it into mcp/ following the "narrowest scope that owns it" coding guideline if MCP will be the only consumer.
They are only used by the MCP server for now, but that will change in upcoming PRs (including the next one coming shortly to type the SSE frames). |
Overview
In
mcp/client.py,async_call_toolpreviously reported failures in two different ways: transport, HTTP, protocol, and handshake errors raisedMcpTransportError(aborting the turn), while tool-layer errors (isError: trueor JSON-RPCINVALID_PARAMS, including unknown tool names) returned{"error": <message>}. Returning an error dictionary with the same type as a successful result required callers to check"error" in resulton an untyped dictionary, which goes againstCODING_GUIDELINES.md§3.2 and §8.With this change,
async_call_toolraisesMcpToolErroron tool-layer failures instead of returning an error dictionary, and all MCP client exceptions inherit from a commonMcpClientErrorbase class.workflows/mcp_loop._run_toolcatchesMcpToolErrorand constructs the same tool-call record as before, so agent and UI behavior are unchanged. Defining a typed exception now allows upcoming callers in the agent loop rewrite to catchMcpToolErrordirectly rather than checking for an"error"key.Related Issues
Changes Made
Changes:
mcp/client.py): Recoverable tool-layer failures now raiseMcpToolError(tool, message)instead of returning{"error": <message>}.McpToolErrorandMcpTransportErrorboth inherit fromMcpClientError(named to avoid colliding withmcp.MCPErrorfrom the SDK).workflows/mcp_loop.py):_run_toolcatchesMcpToolErrorand produces the same error record (status: "error"andresult: json.dumps({"error": message})) instead of checking"error" in result.json_value.py,narratives/AGENTS.md): AddsJsonValueandJsonObjectrecursive type aliases for unstructured JSON payloads, and updatesasync_call_toolto useJsonObject.mcp/client_test.py,workflows/mcp_loop_test.py): Updates the MCP client and tool-loop tests to assertMcpToolErrorandMcpClientErrorwhile verifying that the serialized tool-call error record and warning logs are unchanged.Testing Done
pnpm test:agent(uv run pytest): 865 passed.pnpm test:ui(vitest run): 26 files, 286 passed.pnpm lint:ui(tsc --noEmitandbiome check): passed.pnpm lint:agent(ruff format --check,ruff check, andmypy --strict): passed across all 65 source files.pnpm lint:deploy:terraform fmt -check,pnpm run lint:shell, andpnpm run lint:dockerpassed (terraform validatefails with a pre-existing GCS backend configuration error onmain; nodeploy/files were modified).Checklist:
Manual
Verified against public Data Commons (
MCP_SERVER_URL=https://api.datacommons.org/mcp) withpnpm dev:agentandpnpm dev:ui:POST /agent/chat/stream. Verified thattool_call,data_status,sources,chart_config, streamed answer text,follow_ups, and the signedcompleteterminal frame were emitted as expected._run_toolagainst the live MCP server with an unknown tool name (no_such_tool, triggeringINVALID_PARAMS) and with an invalid date argument onget_observations(date: "not-a-date", triggeringisError: true). Verified that both calls logged a warning and returned the exact samestatus: "error"record and{"error": ...}JSON result string asmain.Risk & Rollback
Agent and UI behavior were not changed: failed tool calls still produce a record with
status: "error"andresult: json.dumps({"error": message}). Byte-for-byte compatibility of the result string is covered bytest_tool_status_follows_the_call_outcomeinworkflows/mcp_loop_test.py. Rollback is reverting the PR.Follow-ups
dict[str, Any]signatures inmcp/client.py(async_get_tools,cached_tools,_tool_dict,_send_tool_call,_ToolCache.tools, and_REFRESH_TASKS) and inmcp/schema.py.data_utils.parse_tool_resultintomcp/client.pyand replace thedict[str, Any]tool-call record type as part of the agent loop rewrite.Checklist
AGENTS.mdand followedCODING_GUIDELINES.md, plusFRONTEND.mdfor UI changes.Note: Only Maintainers can approve and merge PRs. Expected initial review time: 3 business days.