Skip to content

fix: stop reporting MCP client errors to Sentry - #34

Merged
aradng merged 1 commit into
mainfrom
fix/mute-mcp-client-error-noise
Aug 20, 2026
Merged

fix: stop reporting MCP client errors to Sentry#34
aradng merged 1 commit into
mainfrom
fix/mute-mcp-client-error-noise

Conversation

@aradng

@aradng aradng commented Aug 20, 2026

Copy link
Copy Markdown
Owner

The problem

FastMCP's OpenAPI provider calls the mounted app over HTTP and does response.raise_for_status(), so a 4xx the route legitimately returns becomes an HTTPStatusError, is re-raised as ValueError(...) from e, and reaches logger.exception on fastmcp.server.server. Sentry's logging integration turns that record into an issue — while the MCP request itself answered 200:

mechanism : logging
logger    : fastmcp.server.server
handled   : yes
trace     : status "ok", http.response.status_code 200

So every tool call hitting a 404, 403 or 422 pages the team. Asking for a row that does not exist is an answer, not a fault. Seen in the wild as one issue with 14 occurrences whose entire content is 'Dashboard does not exist'.

Why before_send and not a logging filter

They are not interchangeable, which is the crux of this change:

  • A filter on the logger stops the record reaching any handler, so the line vanishes from the console and from logfire too — you lose the diagnostic, not just the alert. _AttrFilter in fastloom/logging/utils.py already encodes the house rule against that: if record.levelno >= logging.ERROR: return True.
  • before_send is scoped to the Sentry client. The log still happens; only the issue does not.

Why not ignore_logger

ignore_logger is also Sentry-scoped, but it is all-or-nothing per logger name. A 500 from a tool travels the identical raise_for_status → ValueError → logger.exception path, so it would bury genuine crashes.

The status comes from the exception, not the message

raise ValueError(...) from e leaves the HTTPStatusError on __cause__, so it is read from there — no regex over log text.

Two links are checked, the logged exception and its direct __cause__, because that is the shape FastMCP produces. Deliberately not a walk up the chain:

  • __context__ must not be followed at all. A genuine failure raised while a 4xx was in flight carries that 4xx as its __context__, so suppressing on context would hide the real error. Tested.
  • A __cause__ walk needs a termination guard. A cycle is reachable from plain raise a from b / raise b from a over reused instances — no manual __cause__ assignment required, verified in 2 hops. An unbounded loop inside before_send would hang a worker on the error path, a worse failure than the noise being removed.

Fixing the depth at the shape we actually receive avoids both, with no sentinel constant to justify. Deeper nesting fails open — the event is reported, so the outcome is noise, never a hang.

Scoped to that one logger, and only wired when FASTMCP_INSTALLED (the branch that already registers MCPIntegration), so a service's own httpx 4xx handling is untouched.

Verified

End to end against a live Sentry client, checking the log line survives:

logged Sentry log emitted
HTTP error 404 suppressed yes
HTTP error 403 suppressed yes
HTTP error 422 suppressed yes
HTTP error 500 captured yes
HTTP error 502 captured yes
non-HTTP failure captured yes
4xx on __context__ only captured yes
other logger, 404 captured yes
cyclic __cause__ chain captured (returns, no spin) yes

14 new tests cover each of those plus the no-exception and wrong-logger paths. 125 passed on the full suite, ruff and format clean. (mypy cannot run in this checkout — returns.contrib.mypy plugin is missing — and fails identically on unmodified main.)

@aradng
aradng force-pushed the fix/mute-mcp-client-error-noise branch 2 times, most recently from b581082 to 5e31d84 Compare August 20, 2026 15:47
FastMCP's OpenAPI provider calls the mounted app over HTTP and does
`response.raise_for_status()`, so a 4xx the route legitimately returns
becomes an `HTTPStatusError`, is re-raised as `ValueError(...) from e`, and
reaches `logger.exception` on `fastmcp.server.server`. Sentry's logging
integration turns that record into an issue even though the MCP request
itself answered 200:

    mechanism : logging
    logger    : fastmcp.server.server
    handled   : yes
    trace     : status "ok", http.response.status_code 200

So every tool call that hits a 404, 403 or 422 pages the team. Asking for a
row that does not exist is an answer, not a fault.

Dropped in `before_send`, not with a logging filter, because the two are not
interchangeable. A filter on the logger stops the record reaching any
handler, so the line disappears from the console and from logfire as well —
and `_AttrFilter` in `fastloom/logging/utils.py` already encodes the house
rule that ERROR records are never filtered out of logs. `before_send` is
scoped to the Sentry client, so the log still happens and only the issue
does not. `ignore_logger` is Sentry-scoped too but all-or-nothing per logger
name, and a 500 travels the identical path, so it would bury real crashes.

The status is read off the exception rather than the message text:
`raise ValueError(...) from e` leaves the `HTTPStatusError` on `__cause__`.

Two links are checked, the logged exception and its direct `__cause__`,
because that is the shape FastMCP produces. Deliberately not a walk up the
chain:

  - `__context__` must not be followed at all. A genuine failure raised
    while a 4xx was in flight carries that 4xx as its context, so
    suppressing on context would hide the real error.
  - a `__cause__` walk needs a termination guard, because a cycle is
    reachable from ordinary `raise a from b` / `raise b from a` over reused
    instances — no manual `__cause__` assignment required. An unbounded loop
    inside `before_send` would hang a worker on the error path, which is a
    worse failure than the noise this removes.

Fixing the depth at the shape we actually receive avoids both, with no
sentinel constant to justify. Deeper nesting fails open — the event is
reported, so the outcome is noise rather than a hang.

Only wired when `FASTMCP_INSTALLED`, on the branch that already registers
`MCPIntegration`, and scoped to that one logger so a service's own httpx
4xx handling is untouched.

Verified end to end against a live client: 4xx suppressed with the log line
still emitted, 5xx and non-HTTP failures captured, a context-only 4xx kept,
other loggers untouched, and a cyclic cause chain returns instead of
spinning.
@aradng
aradng force-pushed the fix/mute-mcp-client-error-noise branch from 5e31d84 to f55cfa2 Compare August 20, 2026 15:52
@aradng
aradng merged commit 7fbbaa2 into main Aug 20, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant