Skip to content

Commit caf9030

Browse files
authored
fix(mcp): report the exception a tool raised when the SDK masks it (#907)
* fix(mcp): report the exception a tool raised when the SDK masks it The MCP SDK's tool dispatch re-raises whatever a tool raised as a ToolError whose message starts "Error executing tool <name>", and mcp 2.1 masks the original text out of that message entirely, keeping it only on __cause__. The $mcp_error_message and $mcp_error_type scalars read $exception_list[0], so on mcp 2.1 every unexpected tool failure reported the same masked string and the failures view lost the reason. The scalars now read the entry behind the dispatch wrapper, which exceptions_from_error_tuple already records from __cause__. The wrapper is matched by type name and message prefix because each SDK major ships its own ToolError class. A wrapper without a chained cause is kept as is, and the $exception sibling still carries the full chain. This broke CI without a repo change: the rolling exclude-newer = "7 days" quarantine made mcp 2.1.0 (published Aug 24) eligible on Aug 31 at 19:04 UTC, between two runs of the same commit. The unpinned mcp>=2,<3 leg resolved 2.0.0 in the morning and 2.1.0 in the evening. mcp 2.1.1 ages in on Sep 1; the fix is verified against 2.0.0, 2.1.0, and 2.1.1. Generated-By: PostHog Desktop Task-Id: ec0cc27f-fc40-4994-b153-2bfe74de1edf * fix(mcp): gate the unwrap on SDK module and traverse nested wrappers Two review findings on the dispatch-wrapper unwrap, both reproduced before fixing. A tool that invokes a failing tool is wrapped once per dispatch, so stepping past a single entry landed on the still-masked inner wrapper. The unwrap now walks every consecutive wrapper to the first real exception; a nested-server test asserts both the inner and the outer event report the root cause. Matching by type name and message prefix alone also unwrapped an application's own exception that happened to be named ToolError with a matching prefix, replacing the message the application chose to surface. The match now additionally requires the entry's recorded module to come from an SDK namespace (mcp., fastmcp.), verified against mcp 1.28.1, mcp 2.0.0/2.1.0/2.1.1, and standalone fastmcp. The unit tests use the real per-major ToolError classes, and a new test pins that a same-named application exception is kept. Generated-By: PostHog Desktop Task-Id: ec0cc27f-fc40-4994-b153-2bfe74de1edf
1 parent bec61e7 commit caf9030

3 files changed

Lines changed: 167 additions & 9 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pypi/posthog: patch
3+
---
4+
5+
MCP tool failures now report the exception the tool actually raised on `$mcp_error_message` and `$mcp_error_type`, stepping past the SDK's dispatch `ToolError` wrapper. mcp 2.1 masks the original message out of that wrapper, which left the failures view with only `Error executing tool <name>`. The `$exception` sibling still carries the full chain.

posthog/mcp/_posthog_events.py

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -157,24 +157,64 @@ def _add_common_properties(event: Event, properties: Dict[str, Any]) -> None:
157157
properties["$set"] = {**identify_actor_data}
158158

159159

160+
_TOOL_DISPATCH_WRAPPERS = ("ToolError", "UnexpectedToolError")
161+
162+
# Where the SDKs define their dispatch wrappers: mcp.server.fastmcp.exceptions
163+
# (mcp 1.x), mcp.server.mcpserver.exceptions (mcp 2.x), fastmcp.exceptions
164+
# (standalone fastmcp). An application's own exception carries its own module,
165+
# so a matching name alone must not unwrap it.
166+
_SDK_MODULE_PREFIXES = ("mcp.", "fastmcp.")
167+
168+
169+
def _is_dispatch_wrapper(entry: Any) -> bool:
170+
if not isinstance(entry, dict):
171+
return False
172+
return (
173+
entry.get("type") in _TOOL_DISPATCH_WRAPPERS
174+
and str(entry.get("module") or "").startswith(_SDK_MODULE_PREFIXES)
175+
and str(entry.get("value", "")).startswith("Error executing tool")
176+
)
177+
178+
179+
def _primary_exception(error: Any) -> Dict[str, Any]:
180+
"""Pick the ``$exception_list`` entry that carries the failure reason.
181+
182+
The MCP SDK's tool dispatch re-raises whatever a tool raised as a
183+
``ToolError`` whose message starts ``Error executing tool <name>``, and
184+
mcp >= 2.1 masks the original text out of that message entirely, keeping
185+
it only on ``__cause__`` — the next entry of the chain here. The wrapper
186+
says nothing the event's tool name does not already say, so the scalars
187+
step past every consecutive wrapper (a tool invoking a failing tool is
188+
wrapped once per dispatch) to the first real exception.
189+
"""
190+
if not isinstance(error, dict):
191+
return {}
192+
exception_list = error.get("$exception_list")
193+
if not isinstance(exception_list, list) or not exception_list:
194+
return {}
195+
index = 0
196+
while (
197+
index + 1 < len(exception_list)
198+
and isinstance(exception_list[index + 1], dict)
199+
and _is_dispatch_wrapper(exception_list[index])
200+
):
201+
index += 1
202+
entry = exception_list[index]
203+
return entry if isinstance(entry, dict) else {}
204+
205+
160206
def _add_error_details(event: Event, properties: Dict[str, Any]) -> None:
161207
"""Surface the failure reason on the primary event itself.
162208
163209
Without these the dashboard has to join to the ``$exception`` sibling to
164210
know *why* a call failed — and that sibling can be switched off with
165211
``enable_exception_autocapture``, or never emitted when no error value was
166212
passed. Both values are read off the ``$exception_list`` the sibling would
167-
carry, so the two always agree; the message is already bounded to
213+
carry — the sibling keeps the full chain while the scalars carry the entry
214+
``_primary_exception`` picks; the message is already bounded to
168215
``_MAX_ERROR_MESSAGE_LENGTH`` because truncation runs before this mapping.
169216
"""
170-
first: Dict[str, Any] = {}
171-
error = event.get("error")
172-
if isinstance(error, dict):
173-
exception_list = error.get("$exception_list")
174-
if isinstance(exception_list, list) and exception_list:
175-
candidate = exception_list[0]
176-
if isinstance(candidate, dict):
177-
first = candidate
217+
first = _primary_exception(event.get("error"))
178218

179219
# An explicit coarse category (e.g. "validation", "timeout") beats the
180220
# thrown type; a custom dispatcher can pass one that means something to the

posthog/test/mcp/test_error_properties.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,23 @@ def make_client(**kwargs):
2323
return client, captured
2424

2525

26+
class ToolError(Exception):
27+
"""An application's own ToolError. It shares the SDK wrapper's name but not
28+
its module, so the unwrap must leave it alone. Module-level so the recorded
29+
type is the bare name and not a ``<locals>`` path."""
30+
31+
32+
def _sdk_tool_error() -> type:
33+
"""The real dispatch-wrapper class for the installed SDK major."""
34+
from posthog.test.mcp._helpers import MCP_MAJOR
35+
36+
if MCP_MAJOR >= 2:
37+
from mcp.server.mcpserver.exceptions import ToolError as SDKToolError
38+
else:
39+
from mcp.server.fastmcp.exceptions import ToolError as SDKToolError
40+
return SDKToolError
41+
42+
2643
async def test_failed_call_carries_message_and_type():
2744
client, captured = make_client()
2845
client.capture_tool_call("add", is_error=True, error=ValueError("bad input"))
@@ -153,6 +170,102 @@ def boom() -> str:
153170
assert "explode" in props[P.ERROR_MESSAGE]
154171

155172

173+
async def test_the_sdk_dispatch_wrapper_is_unwrapped_to_its_cause():
174+
"""mcp >= 2.1 masks an unexpected tool exception to ``Error executing tool
175+
<name>`` and keeps the original only on ``__cause__``. The scalars must
176+
carry that original; the ``$exception`` sibling keeps the full chain."""
177+
client, captured = make_client()
178+
sdk_tool_error = _sdk_tool_error()
179+
180+
try:
181+
try:
182+
raise ValueError("explode")
183+
except ValueError as original:
184+
raise sdk_tool_error("Error executing tool boom") from original
185+
except sdk_tool_error as wrapper:
186+
client.capture_tool_call("boom", is_error=True, error=wrapper)
187+
await _flush()
188+
189+
props = _events(captured, "$mcp_tool_call")[0]["properties"]
190+
assert props[P.ERROR_MESSAGE] == "explode"
191+
assert props[P.ERROR_TYPE] == "ValueError"
192+
sibling = _events(captured, "$exception")[0]["properties"]["$exception_list"]
193+
assert sibling[0]["type"] == "ToolError"
194+
195+
196+
async def test_a_dispatch_wrapper_without_a_cause_is_kept():
197+
"""With no chained cause the wrapper's own message is all there is."""
198+
client, captured = make_client()
199+
200+
client.capture_tool_call(
201+
"boom", is_error=True, error=_sdk_tool_error()("Error executing tool boom")
202+
)
203+
await _flush()
204+
205+
props = _events(captured, "$mcp_tool_call")[0]["properties"]
206+
assert props[P.ERROR_MESSAGE] == "Error executing tool boom"
207+
assert props[P.ERROR_TYPE] == "ToolError"
208+
209+
210+
async def test_an_application_error_sharing_the_wrapper_name_is_kept():
211+
"""``capture_tool_call`` accepts arbitrary exceptions. The wrapper is
212+
matched by SDK module, not just name, so an application's own ToolError
213+
keeps the message and type the application chose to surface."""
214+
client, captured = make_client()
215+
216+
try:
217+
try:
218+
raise ValueError("inner detail")
219+
except ValueError as original:
220+
raise ToolError("Error executing tool application task") from original
221+
except ToolError as wrapper:
222+
client.capture_tool_call("task", is_error=True, error=wrapper)
223+
await _flush()
224+
225+
props = _events(captured, "$mcp_tool_call")[0]["properties"]
226+
assert props[P.ERROR_MESSAGE] == "Error executing tool application task"
227+
assert props[P.ERROR_TYPE] == "ToolError"
228+
229+
230+
async def test_nested_dispatch_wrappers_unwrap_to_the_root_cause():
231+
"""An outer tool that invokes a failing inner tool gets wrapped twice —
232+
once per dispatch — so the scalars must step past every wrapper, not just
233+
the first, on both the inner and the outer event."""
234+
from posthog.test.mcp._helpers import MCP_MAJOR, FakeClient
235+
236+
if MCP_MAJOR >= 2:
237+
from mcp.server.mcpserver import MCPServer as Server
238+
else:
239+
from mcp.server.fastmcp import FastMCP as Server
240+
241+
from posthog.mcp import instrument
242+
243+
server = Server("nested-e2e")
244+
245+
@server.tool()
246+
def inner() -> str:
247+
raise ValueError("root failure")
248+
249+
@server.tool()
250+
async def outer() -> str:
251+
return await server._tool_manager.call_tool("inner", {})
252+
253+
client = FakeClient()
254+
instrument(server, client)
255+
256+
try:
257+
await server._tool_manager.call_tool("outer", {})
258+
except Exception:
259+
pass
260+
await _flush()
261+
262+
calls = _events(client, "$mcp_tool_call")
263+
assert len(calls) == 2
264+
for call in calls:
265+
assert call["properties"][P.IS_ERROR] is True
266+
assert "root failure" in call["properties"][P.ERROR_MESSAGE]
267+
268+
156269
async def test_a_secret_in_the_message_is_redacted_on_both_surfaces():
157270
"""An exception message is free text a server wrote, so it can carry the
158271
credential that caused the failure. It must be redacted before it leaves —

0 commit comments

Comments
 (0)