Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,9 @@ jobs:
# the lockfile's mcp 1.x (also exercised incidentally by the `tests`
# matrix — this leg exists as an explicit, named signal); the v2 leg
# (spec 2026-07-28) swaps in mcp>=2 and standalone FastMCP 4, which uses
# the v2 registry. The v2-bare leg runs mcp>=2 without FastMCP: importing
# the v2 registry. The v1-fastmcp2 leg keeps mcp 1.x and swaps in
# standalone FastMCP 2, which dispatches tools without a public
# call_tool. The v2-bare leg runs mcp>=2 without FastMCP: importing
# FastMCP 4 patches camelCase properties onto mcp_types models for the
# whole process, which hides reads that plain mcp 2.x rejects.
# posthog/test/mcp/conftest.py splits collection by major.
Expand All @@ -166,8 +168,10 @@ jobs:
strategy:
matrix:
python-version: ['3.10', '3.14']
mcp-major: ['v1', 'v2', 'v2-bare']
mcp-major: ['v1', 'v1-fastmcp2', 'v2', 'v2-bare']
include:
- mcp-major: 'v1-fastmcp2'
mcp-packages: "'mcp>=1.28.1,<2' 'fastmcp>=2,<3'"
- mcp-major: 'v2'
mcp-packages: "'mcp>=2,<3' 'fastmcp>=4,<5'"
- mcp-major: 'v2-bare'
Expand All @@ -194,7 +198,7 @@ jobs:
run: |
UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --extra test

- name: Swap in MCP SDK v2
- name: Swap in MCP packages
if: matrix.mcp-major != 'v1'
shell: bash
run: |
Expand Down
5 changes: 5 additions & 0 deletions .sampo/changesets/fastmcp-tool-error-exception.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: patch
---

`posthog.mcp`: a failed tool call on a standalone FastMCP server now reports the exception the tool raised, with its cause chain and stack frames, and `$mcp_error_type` names its class. Before, FastMCP converted the exception into an error result first, so the failure was a generic `Error` with no frames.
7 changes: 5 additions & 2 deletions posthog/mcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ def instrument(
from ._instrument_fastmcp import instrument_fastmcp
from ._instrument_lowlevel import instrument_fastmcp_v2, instrument_low_level
from ._instrument_v2 import instrument_lowlevel_v2, instrument_mcpserver_v2
from ._raised_tool_error import wrap_tool_dispatch

client = _resolve_client(posthog_client)
if client is None:
Expand All @@ -306,8 +307,10 @@ def instrument(
server_build=server_build,
)

if is_fastmcp_v2(server) and uses_v2_handler_registry(key):
data.standalone_fastmcp = weakref.ref(server)
if is_fastmcp_v2(server):
wrap_tool_dispatch(server)
if uses_v2_handler_registry(key):
data.standalone_fastmcp = weakref.ref(server)

# A standalone FastMCP wrapper and its low-level server share one tracking
# key, so instrumenting the second of the pair must still attach what only
Expand Down
5 changes: 4 additions & 1 deletion posthog/mcp/_instrument_lowlevel.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
from ._model_parameters import request_meta_from_context
from ._model_parameters import can_inject_model_parameter, is_capture_model_enabled
from ._output_instructions import mirror_instructions_into_structured_content
from ._raised_tool_error import raised_tool_error
from .logger import log, warn
from .tools import get_more_tools_result_text

Expand Down Expand Up @@ -282,7 +283,8 @@ async def handler(req: Any) -> Any:

start = time.monotonic()
try:
result = await original(req)
with raised_tool_error(high_level) as raised:
result = await original(req)
except Exception as error:
if await lifecycle.record_if_unknown_tool(
await registered_tool_exists(high_level, name)
Expand Down Expand Up @@ -349,6 +351,7 @@ async def handler(req: Any) -> Any:
call_result,
duration_ms,
conversation_id_delivered=delivered,
error=raised.error,
)
return result

Expand Down
9 changes: 7 additions & 2 deletions posthog/mcp/_instrument_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
request_meta_from_context,
)
from ._output_instructions import mirror_instructions_into_structured_content
from ._raised_tool_error import raised_tool_error
from ._tool_input import get_registered_tool_input_schema
from .asgi import get_mcp_session
from .logger import log
Expand Down Expand Up @@ -673,7 +674,8 @@ async def handler(ctx: Any, params: Any) -> Any:

start = time.monotonic()
try:
result = await original(ctx, params)
with raised_tool_error(standalone) as raised:
result = await original(ctx, params)
except Exception as error:
if await lifecycle.record_if_unknown_tool(
await registered_tool_exists(standalone, name)
Expand Down Expand Up @@ -702,7 +704,10 @@ async def handler(ctx: Any, params: Any) -> Any:
return result

await lifecycle.record_result(
result, duration_ms, conversation_id_delivered=delivered
result,
duration_ms,
conversation_id_delivered=delivered,
error=raised.error,
)
return result

Expand Down
17 changes: 11 additions & 6 deletions posthog/mcp/_instrumentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -665,7 +665,12 @@ async def record_error(self, error: Any, duration_ms: float) -> None:
)

async def record_result(
self, result: Any, duration_ms: float, *, conversation_id_delivered: bool
self,
result: Any,
duration_ms: float,
*,
conversation_id_delivered: bool,
error: Optional[BaseException] = None,
) -> None:
conversation_id = self._anchored_conversation_id(conversation_id_delivered)
session_id = await self.prepare_session(conversation_id)
Expand All @@ -678,6 +683,7 @@ async def record_result(
allow_self_reported_model=self.allow_self_reported_model,
analytics_owned_parameters=self.analytics_owned_parameters,
result=result,
error=error,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 · Sanitize source context before capturing recovered exceptions

On failed standalone FastMCP calls, record_result now forwards exceptions with source snippets in their stack frames. _sanitize_exception_values scrubs only exception value, leaving pre_context, context_line, and post_context untouched. URL passwords and PostHog tokens in those lines reach $exception verbatim, violating the MCP privacy contract; sanitize frame strings before capture.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 An AI agent wrote this on behalf of @ioannisj.

+1, on fastmcp 3.2.0 a phx_ token written in the raise line is redacted in value but shows up as-is in that frame's context_line. Base sent no frames here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 34fbaeb. The sanitizer now runs pre_context, context_line and post_context of every frame through sanitize_captured_value, the same pass value gets. That also covers the official SDK path, which already sent frames. test_secrets_in_frame_source_lines_are_redacted puts a database URL with a password on the line before the raise. It fails without the change and passes with it, on fastmcp 2.14.7, 3.2 and 4.1.8.

duration_ms=duration_ms,
client_name=self.client_name,
client_version=self.client_version,
Expand Down Expand Up @@ -813,14 +819,13 @@ async def record_tool_call(
event["llm_model"] = model
event["llm_model_source"] = source

if result is not None:
event["response"] = _wrap_response(result)
if error is None and is_tool_result_error(result):
error = result
if error is not None:
event["is_error"] = True
event["error"] = capture_exception(error)
elif result is not None:
event["response"] = _wrap_response(result)
if is_tool_result_error(result):
event["is_error"] = True
event["error"] = capture_exception(result)

props = await resolve_event_properties(data, request, extra)
input_aliases = None
Expand Down
1 change: 1 addition & 0 deletions posthog/mcp/_posthog_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ def _add_common_properties(event: Event, properties: Dict[str, Any]) -> None:
# its text, so reporting it loses nothing.
_DISPATCH_WRAPPERS = (
(("ToolError", "UnexpectedToolError"), "Error executing tool"),
(("ToolError",), "Error calling tool '"),
(("MCPError", "UnexpectedResourceError"), "Error reading resource"),
)

Expand Down
83 changes: 83 additions & 0 deletions posthog/mcp/_raised_tool_error.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""Recover the exception behind a standalone FastMCP ``isError`` tool result.

FastMCP's ``tools/call`` handler, the one we wrap, turns whatever its tool
dispatch raised into ``CallToolResult(isError=True)``, keeping only the
message text. Wrapping that dispatch on the server instance sees the exception
first, and hands it to the enclosing handler through a context variable.
"""

from __future__ import annotations

from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass
from typing import Any, Iterator, Optional

_WRAPPED_FLAG = "__posthog_mcp_wrapped__"

# fastmcp 3+ dispatches tools/call through the public `call_tool`; 2.x has no
# `call_tool` and dispatches through `_call_tool_middleware`.
_DISPATCH_METHODS = ("call_tool", "_call_tool_middleware")


@dataclass
class RaisedToolError:
error: Optional[BaseException] = None


@dataclass
class _Slot:
server: Any
error: Optional[BaseException] = None
open: bool = True


_current: ContextVar[Optional[_Slot]] = ContextVar(
"posthog_mcp_raised_tool_error", default=None
)


def wrap_tool_dispatch(server: Any) -> None:
method = next((m for m in _DISPATCH_METHODS if hasattr(server, m)), None)
if method is None:
return
original = getattr(server, method)
if getattr(original, _WRAPPED_FLAG, False):
return

async def dispatch(*args: Any, **kwargs: Any) -> Any:
# A tool calling another tool nests these calls. The outermost call
# settles last, so the slot ends holding the outcome the client sees.
try:
result = await original(*args, **kwargs)
except Exception as error:
_settle(server, error)
raise
_settle(server, None)
return result

setattr(dispatch, _WRAPPED_FLAG, True)
setattr(server, method, dispatch)


def _settle(server: Any, error: Optional[BaseException]) -> None:
# Another server's dispatch, reached from inside a tool, must not write
# its outcome onto this call, nor may a background task that outlives it.
slot = _current.get()
if slot is not None and slot.open and slot.server is server:
slot.error = error


@contextmanager
def raised_tool_error(server: Any) -> Iterator[RaisedToolError]:
slot = _Slot(server)
raised = RaisedToolError()
token = _current.set(slot)
try:
yield raised
finally:
_current.reset(token)
# A background task started by the tool keeps a copy of this context,
# so the slot outlives the call; it must not keep the traceback alive.
raised.error, slot.error = slot.error, None
slot.open = False
Loading
Loading