Skip to content

feat(mcp): add observability to MCP service#41921

Open
aminghadersohi wants to merge 6 commits into
apache:masterfrom
aminghadersohi:mcp-observability
Open

feat(mcp): add observability to MCP service#41921
aminghadersohi wants to merge 6 commits into
apache:masterfrom
aminghadersohi:mcp-observability

Conversation

@aminghadersohi

Copy link
Copy Markdown
Contributor

SUMMARY

The MCP service emitted zero StatsD/Prometheus metrics, and its middleware swallowed every exception before any error tracker could see them. This adds:

  • Per-tool metrics: success/error counters and timing in LoggingMiddleware.on_call_tool, plus a user-vs-system error counter split in GlobalErrorHandlerMiddleware._handle_error — mirrors the @statsd_metrics pattern already used in views/base_api.py.
  • Fixed silently-dropped audit rows: LoggingMiddleware skipped its event_logger.log(...) call entirely when has_app_context() was False at the time the middleware finally block ran (the per-tool app context had already exited). Both call sites now wrap the log call in the existing _get_app_context_manager() helper instead of skipping it.
  • Pluggable MCP_ERROR_HOOK config: a vendor-neutral Callable[[Exception, dict], None] | None hook invoked for system-class errors in GlobalErrorHandlerMiddleware._handle_error (primary capture point) and in StructuredContentStripperMiddleware.on_call_tool's last-resort except block (for exceptions that bypass the primary handler entirely). This lets operators wire an external error tracker (e.g. Sentry) without the OSS repo taking a hard dependency on any vendor SDK. PRODUCTION.md is updated to note that FlaskIntegration does not cover the FastMCP tool-execution path and shows wiring Sentry via the hook instead.
  • error_type extraction: LoggingMiddleware previously substring-sniffed "error_type" in serialized tool responses only to decide success/failure, discarding the actual value. It's now parsed and included in the log line, curated payload, and metric tag.
  • Small fixes: the generic "Internal error" branch used a second-granularity f"err_{int(time.time())}" ID that collides under concurrent failures — replaced with the existing per-call mcp_call_id. Added event_logger.log_context instrumentation to get_chart_type_schema (previously the only uninstrumented tool). Added server-side logger.warning/logger.exception calls next to several return XError(...) sites in get_chart_data, get_tag_info, and query_dataset that only logged to the MCP client (ctx.error/ctx.warning, which never reaches server logs) or didn't log at all.

BEFORE/AFTER

Before: grep -r "stats_logger\|statsd\|incr(\|timing(" superset/mcp_service/ → no hits. No error tracker could ever see an MCP tool failure, and DB audit rows for mcp_tool_call/mcp_message were silently dropped whenever the ambient Flask app context had already exited.

After: Per-tool mcp.tool.{name}.{success|error} counters and mcp.tool.{name}.time timing are emitted on every tool call. Audit rows are always written. Operators can wire MCP_ERROR_HOOK to forward system-class errors to an external tracker.

TESTING INSTRUCTIONS

  • Extended tests/unit_tests/mcp_service/test_middleware.py and test_middleware_logging.py with unit tests covering: metrics emission (success/error counters + timing, including the call_tool proxy tool-name resolution case), error_type extraction from structured responses, the MCP_ERROR_HOOK invocation/no-op/exception-swallowing paths in both middlewares, the mcp_call_id-as-error_id fix, and regression tests confirming both LoggingMiddleware.on_call_tool and on_message route their audit-row log call through _get_app_context_manager() rather than conditionally skipping it.
  • pytest tests/unit_tests/mcp_service/ — 2691 passed.
  • ruff check / ruff format --check / mypy / pylint all pass on the changed files.

ADDITIONAL INFORMATION

  • Has associated tests
  • Has screenshots (N/A — backend-only observability change)
  • Changes UI
  • Introduces new feature or API
  • Removes existing feature

… error hook

The MCP service emitted zero StatsD metrics and its middleware swallowed
every exception before any error tracker could see them. This adds
per-tool success/error counters and timing in LoggingMiddleware and
GlobalErrorHandlerMiddleware (mirroring the @statsd_metrics pattern in
base_api.py), fixes has_app_context() silently dropping DB audit rows by
wrapping event_logger calls in the existing app-context manager, adds a
vendor-neutral MCP_ERROR_HOOK config for wiring an external error tracker,
surfaces the previously-discarded error_type from structured error
responses, and adds server-side logging at several ops-invisible error
return sites.
@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 1.88679% with 104 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.14%. Comparing base (1c0f259) to head (00a4944).
⚠️ Report is 11 commits behind head on master.

Files with missing lines Patch % Lines
superset/mcp_service/middleware.py 0.00% 83 Missing ⚠️
superset/mcp_service/chart/tool/get_chart_data.py 0.00% 17 Missing ⚠️
...et/mcp_service/chart/tool/get_chart_type_schema.py 33.33% 2 Missing ⚠️
superset/mcp_service/dataset/tool/query_dataset.py 0.00% 1 Missing ⚠️
superset/mcp_service/tag/tool/get_tag_info.py 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #41921      +/-   ##
==========================================
- Coverage   65.19%   65.14%   -0.05%     
==========================================
  Files        2790     2790              
  Lines      156809   156890      +81     
  Branches    35808    35811       +3     
==========================================
- Hits       102229   102207      -22     
- Misses      52621    52725     +104     
+ Partials     1959     1958       -1     
Flag Coverage Δ
hive 38.49% <1.88%> (-0.05%) ⬇️
mysql 57.57% <1.88%> (-0.07%) ⬇️
postgres 57.63% <1.88%> (-0.07%) ⬇️
presto 40.42% <1.88%> (-0.05%) ⬇️
python 59.02% <1.88%> (-0.07%) ⬇️
sqlite 57.24% <1.88%> (-0.07%) ⬇️
unit 100.00% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@aminghadersohi
aminghadersohi marked this pull request as ready for review July 18, 2026 21:39
@dosubot dosubot Bot added change:backend Requires changing the backend infra:logging Infra setup - logging labels Jul 18, 2026
@bito-code-review

bito-code-review Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #fce5d0

Actionable Suggestions - 0
Review Details
  • Files reviewed - 8 · Commit Range: 9ab2905..4615d38
    • superset/mcp_service/chart/tool/get_chart_data.py
    • superset/mcp_service/chart/tool/get_chart_type_schema.py
    • superset/mcp_service/dataset/tool/query_dataset.py
    • superset/mcp_service/mcp_config.py
    • superset/mcp_service/middleware.py
    • superset/mcp_service/tag/tool/get_tag_info.py
    • tests/unit_tests/mcp_service/test_middleware.py
    • tests/unit_tests/mcp_service/test_middleware_logging.py
  • Files skipped - 1
    • superset/mcp_service/PRODUCTION.md - Reason: Filter setting
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

@rebenitez1802 rebenitez1802 left a comment

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.

Request changes: solid, well-tested observability work — the audit-row fix, MCP_ERROR_HOOK design (no double-hook, failures swallowed), and the error_id/error_type changes are all correct. But the metrics it adds are double-counted and misclassified, which undermines the feature's own purpose, and one metric key is built from unbounded client input. Details below.

🟡 Medium — Error counters double-counted and misclassified across the two middlewares
GlobalErrorHandlerMiddleware._handle_error (middleware.py:688) increments mcp.tool.{tool}.{warning|error} then re-raises ToolError; that exception propagates to the outer LoggingMiddleware.on_call_tool, whose finally (middleware.py:417) increments mcp.tool.{tool}.error again. So a raised system error counts .error twice, and a raised user error hits both .warning (GEH) and .error (Logging) — polluting the very success/warning/error split the PR says it mirrors from base_api.py. A tool that returns a structured error (no raise) is counted once by Logging only — a third inconsistent path. In the tool-search-proxy case the two also key differently (call_tool in GEH vs the resolved name in Logging). Fix: emit the per-tool outcome from exactly one place — e.g. drop the incr from _handle_error and move the _is_user_error classification into LoggingMiddleware so it can emit .success/.warning/.error itself. (Dormant under the default no-op DummyStatsLogger, but active in exactly the StatsD deployments this targets.)

🟡 Medium — Unbounded / injectable StatsD metric key via the call_tool proxy name
In on_call_tool, metric_tool = mcp_tool or tool_name (middleware.py:416), where mcp_tool = _resolve_tool_name(...) returns params["name"] from the call_tool search proxy verbatim, with no check against the registered tool set (middleware.py:324-331). It flows unsanitized into incr(f"mcp.tool.{metric_tool}.error") / timing(...), and the finally emits it even when the proxied tool name is bogus. With a real StatsdStatsLogger (keys passed straight to pystatsd, stats_logger.py:96-103) any authenticated client can (a) create unbounded metric series and (b) inject forged StatsD lines if name contains \n/:/|. base_api.py avoids this by keying on code-derived class/method names. Not an RBAC-matrix violation per SECURITY.md (operator-owned metrics infra) — a robustness bug, not a CVE. Fix: validate metric_tool against the registered tools and fall back to a constant ("call_tool"/"unknown") otherwise; keep the raw name only in the (non-StatsD) curated payload.

🟡 Medium — Test gap lets the double-count ship undetected
Every new stats test exercises one middleware in isolation and asserts incr.assert_called_once_with(...) (test_middleware.py:1363, test_middleware_logging.py:702), so each passes even though both middlewares fire in the wired chain. The one real-chain test (TestMiddlewareChainOrder) never patches stats_logger_manager. Fix: add a chain-level test that drives a raising tool through build_middleware_list(), patches stats_logger_manager, and asserts the exact set of incr calls for one failed call — that pins whether the double-emission is intended.

🟢 Low — event_logger.log in the finally isn't wrapped in try/except
on_call_tool (middleware.py:384-393) and on_message (433-450) call event_logger.log unguarded — unlike sibling _handle_error (670-684) and ResponseSizeGuardMiddleware. The default DBEventLogger swallows SQLAlchemyError, but a custom EVENT_LOGGER that raises (or a failure entering the app context) would mask the tool's real exception on the error path and skip the incr/timing that follow it. Fix: wrap the log call in try/except like _handle_error does (or emit the metrics before the log).

🟢 Low — Hook-context contract is documented inaccurately
PRODUCTION.md and the mcp_config.py comment say context includes tool_name, mcp_call_id, user_id, error_type, sanitized_message, duration_ms "already sanitized." But the last-resort site in StructuredContentStripperMiddleware.on_call_tool (middleware.py:540-547) passes only tool_name, mcp_call_id, error_type, and the hook's first arg is the raw exception (only sanitized_message is scrubbed). A hook indexing context['user_id'] would fail on that path (swallowed, so the report is silently lost). Fix: either populate the missing keys (even as None) at the last-resort site, or document that only tool_name/mcp_call_id/error_type are guaranteed and that the raw exception may contain sensitive data.

🟢 Low — New OAuth logger.info in get_chart_data sits on a rarely-reached branch
The added logger.info("...OAuth authentication required...") (get_chart_data.py:916) is in the outer except OAuth2RedirectError, but query-time OAuth errors from command.run() are caught first by the inner except (..., SupersetException, ...) (~:896, since OAuth2RedirectError subclasses SupersetException) and returned as a generic DataError. The routing is pre-existing — just be aware this new log (and the OAuth redirect response) rarely fires. query_dataset.py orders its handlers correctly.

🟢 Low — Doc nits in the Sentry example
PRODUCTION.md uses the deprecated sentry_sdk.push_scope() (removed-path in sentry-sdk 2.x) → new_scope() (inline suggestion below). Also add a note — as the sibling MCP_CHART_PLUGIN_ENABLED_FUNC comment does — that MCP_ERROR_HOOK runs synchronously on the asyncio event loop, so a blocking hook stalls tool handling; offload network I/O to a background transport (the Sentry SDK already does).


Confirmed correct and not issues: the has_app_context()_get_app_context_manager() change is the intended audit-row fix (returns nullcontext() under a request context, no g.user races); the MCP_ERROR_HOOK typing/imports are valid; and the hook is not double-invoked (the isinstance(e, ToolError) guard prevents it).

Reviewed with a multi-agent pass (adversarial verification of the two metric findings + sweeps over the diff, tests, and docs).

Comment thread superset/mcp_service/PRODUCTION.md Outdated
sanitized_message, and duration_ms — already sanitized by the MCP
service before this hook runs.
"""
with sentry_sdk.push_scope() as scope:

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.

Deprecated in sentry-sdk 2.x — push_scope() is on the removal path. Use new_scope() (same body):

Suggested change
with sentry_sdk.push_scope() as scope:
with sentry_sdk.new_scope() as scope:

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.

Applied in 46c2332 — switched to sentry_sdk.new_scope().

…tric keys

Per-tool outcome counters are emitted from exactly one place
(LoggingMiddleware), classified user/system via the ToolError __cause__
unwrap, so raised errors are no longer double-counted between the two
middlewares. Metric keys are validated against the FastMCP tool registry
so client-controlled tool names cannot mint unbounded StatsD series or
inject metadata characters. Also: event-logger calls in the logging
middleware are guarded so a failing logger cannot mask the tool's real
exception; the last-resort error-hook site populates the full context-key
contract; docs switch to sentry_sdk.new_scope() and document the hook's
synchronous execution and raw-exception caveat; OAuth errors in
get_chart_data re-raise past the generic inner handler so the dedicated
OAuth branches are reachable. Chain-level tests pin the exact set of
stats calls through build_middleware_list().
@aminghadersohi

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — all findings addressed in 46c2332.

Double-counted / misclassified error counters — took the suggested fix: the per-tool outcome counter is now emitted from exactly one place, LoggingMiddleware (new _emit_call_metrics), and the incr in GlobalErrorHandlerMiddleware._handle_error is removed (replaced with a comment explaining why it must not emit). The user/system classification is recovered in LoggingMiddleware by unwrapping the ToolError.__cause__ that _handle_error attaches via raise ... from error, then running _is_user_error on the original exception — so raised user errors count .warning, raised system errors count .error, each exactly once. This also fixes the keying inconsistency in the proxy case (the single emission point uses the resolved tool name) and makes the logged error_type the original exception class rather than ToolError. Structured error responses (no raise) still count .error once — their free-form error_type can't be reliably mapped to user/system, so the parsed value goes to the curated payload instead; noted in the docstring.

Unbounded / injectable metric key — added _resolve_metric_tool_name: the candidate name (proxied or direct — the raw message name is client-controlled on the error path too) is validated against the FastMCP tool registry via context.fastmcp_context.fastmcp.get_tool(...); unregistered names fall back to "call_tool" (proxied) or "unknown" (direct). When no registry is reachable from the context (mocked contexts in tests), a conservative charset/length regex applies instead, so metadata characters and unbounded lengths never reach the key in any mode. The raw name still reaches the curated payload and log line, which are not StatsD keys.

Chain-level test gap — added TestChainLevelStatsMetrics: drives a raising tool (user-error and system-error variants, plus a success case) through the real build_middleware_list() chain with stats_logger_manager patched and asserts the exact list of incr/timing calls — one outcome counter per call, correctly classified. The old per-middleware GEH stats tests are repurposed to assert GEH emits nothing (regression guard against reintroducing the double emission).

Unguarded event_logger.log in the finally — both on_call_tool and on_message now wrap the app-context entry + log call in try/except (matching _handle_error), so a failing custom EVENT_LOGGER can't mask the tool's real exception or skip the metrics/log line that follow.

Hook-context contract — the last-resort site in StructuredContentStripperMiddleware now populates all six contract keys (user_id/duration_ms as None, sanitized_message via the sanitizer), and the stripper test pins the exact key set. Docs in mcp_config.py and PRODUCTION.md now state the guarantee explicitly and warn that the first argument is the raw exception (only sanitized_message is scrubbed).

OAuth branch reachability in get_chart_data — agreed it was pre-existing, but since the new log line sits on that branch I fixed the routing: the inner generic handler is now preceded by except (OAuth2RedirectError, OAuth2Error): raise, so query-time OAuth errors reach the dedicated outer handlers (and return the redirect message instead of a generic DataError). No existing tests pinned the old behavior.

Doc nitspush_scope()new_scope() (your inline suggestion), and both PRODUCTION.md and the mcp_config.py comment now note the hook runs synchronously on the event loop and that network I/O should go through a background transport (which Sentry's SDK already does).

Full MCP unit suite passes locally (3103 tests; the one failure, test_mcp_e2e_smoke, fails identically on the merge base without these changes — local-env artifact, green in CI).

@netlify

netlify Bot commented Jul 22, 2026

Copy link
Copy Markdown

Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit 7bc4655
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6a60f490a61cb80008c6b782
😎 Deploy Preview https://deploy-preview-41921--superset-docs-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

Comment thread superset/mcp_service/middleware.py Outdated
Comment on lines +509 to +516
await self._emit_call_metrics(
context,
tool_name,
mcp_tool,
success=success,
raised_is_user_error=raised_is_user_error,
duration_ms=duration_ms,
)

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.

Suggestion: The metrics emission is awaited directly inside the finally block without protection, so any runtime failure in stats logging (for example, backend outage or client error) will raise a new exception and can mask the original tool result/exception. Wrap this call in its own try/except and swallow/log metric failures so observability side effects never alter MCP tool behavior. [possible bug]

Severity Level: Major ⚠️
- ❌ MCP tool calls can fail when stats logger misbehaves.
- ⚠️ Original ToolError replaced by metrics RuntimeError in finally.
- ⚠️ Downstream MCP clients see spurious failures unrelated to tools.
Steps of Reproduction ✅
1. In `superset/mcp_service/middleware.py:409-517`, inspect
`LoggingMiddleware.on_call_tool`; its `finally` block ends with an awaited call to
`_emit_call_metrics` at lines 509-516.

2. In a test, import `LoggingMiddleware` from `superset/mcp_service/middleware.py` and
construct a middleware instance plus a minimal `MiddlewareContext` and `call_next` that
returns a successful `ToolResult`.

3. Monkeypatch `stats_logger_manager.instance.incr` or `timing` (called inside
`_emit_call_metrics` at lines 398-405) to raise `RuntimeError("metrics backend down")`,
simulating a failing metrics backend or misconfigured client.

4. Await `LoggingMiddleware.on_call_tool(context, call_next)`; `call_next` returns
successfully, but when the `finally` block executes, the awaited `_emit_call_metrics` call
raises `RuntimeError`, causing `on_call_tool` to propagate this new exception instead of
returning the `ToolResult` (or instead of preserving any original exception),
demonstrating that metrics failures can alter or mask the real tool outcome.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset/mcp_service/middleware.py
**Line:** 509:516
**Comment:**
	*Possible Bug: The metrics emission is awaited directly inside the `finally` block without protection, so any runtime failure in stats logging (for example, backend outage or client error) will raise a new exception and can mask the original tool result/exception. Wrap this call in its own `try/except` and swallow/log metric failures so observability side effects never alter MCP tool behavior.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

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.

Valid catch — fixed in 664b5c7. The await self._emit_call_metrics(...) in the finally is now wrapped in its own try/except that logs a warning and swallows the failure, matching the guard already applied to the event_logger.log call above it. Added two regression tests: a stats client raising RuntimeError neither turns a successful call into an error (result still returned) nor replaces the tool's original exception (the original ValueError still propagates, not the metrics RuntimeError).

@bito-code-review

bito-code-review Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #a1e59b

Actionable Suggestions - 0
Additional Suggestions - 1
  • superset/mcp_service/chart/tool/get_chart_data.py - 1
    • Missing OAuth test coverage · Line 896-901
      The fix correctly re-raises OAuth exceptions before the generic `SupersetException` handler. OAuth errors subclass `SupersetErrorException` → `SupersetException`, so without this re-raise, lines 917-941's dedicated OAuth handlers would never execute. While the fix is correct, existing test coverage lacks OAuth error scenarios for this function.
Review Details
  • Files reviewed - 5 · Commit Range: 4615d38..46c2332
    • superset/mcp_service/chart/tool/get_chart_data.py
    • superset/mcp_service/mcp_config.py
    • superset/mcp_service/middleware.py
    • tests/unit_tests/mcp_service/test_middleware.py
    • tests/unit_tests/mcp_service/test_middleware_logging.py
  • Files skipped - 1
    • superset/mcp_service/PRODUCTION.md - Reason: Filter setting
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

The metrics emission in LoggingMiddleware's finally block is wrapped in
try/except so a failing stats backend can never mask the tool's real
result or exception — matching the guard already applied to the
event-logger call. Adds tests that a raising stats client neither turns
a successful call into an error nor replaces the original exception,
and OAuth routing tests for get_chart_data verifying query-time
OAuth2RedirectError/OAuth2Error reach the dedicated handlers instead of
being swallowed into a generic DataError.
@aminghadersohi

Copy link
Copy Markdown
Contributor Author

Addressed the two remaining automated-review items in 664b5c7:

Unprotected metrics emission in the finally block (CodeAnt) — valid: _emit_call_metrics was awaited unguarded, so a raising stats client could mask the tool's real result or exception. It's now wrapped in its own try/except (warning-logged, swallowed), matching the guard on the event_logger.log call. Two regression tests pin the behavior: with incr raising RuntimeError, a successful call still returns its result, and a failing call still propagates its original ValueError rather than the metrics error.

Missing OAuth test coverage for the get_chart_data re-raise fix (Bito) — added TestOAuthErrorRouting driving the full tool through the FastMCP client with ChartDataCommand.run raising: query-time OAuth2RedirectError now provably returns error_type="OAUTH2_REDIRECT" (not a generic DataError), and OAuth2Error returns error_type="OAUTH2_REDIRECT_ERROR".

Also merged the latest master into the branch (includes #41923's dead-middleware removal — no conflicts with this PR's changes). Full MCP unit suite: 3125 passed locally.

@rebenitez1802 rebenitez1802 left a comment

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.

Follow-up (round 2) — re-reviewed at 00a4944 after 664b5c7 (fix: guard metrics emission in finally; add OAuth routing tests).

All blocking items from my earlier review are verified fixed — thanks for the fast turnaround. I re-ran an adversarial pass over each fix and they hold up:

  • Metric double-count → single emission point LoggingMiddleware._emit_call_metrics with a real success/warning/error split (raised user→warning, raised system→error); _handle_error no longer emits. The chain-level test proves exactly-one counter per call. ✅
  • Injectable/unbounded metric key_resolve_metric_tool_name validates against the FastMCP registry and falls back to call_tool/unknown; the regex path isn't reachable on a real tool call (fastmcp_context is always set) and its charset blocks StatsD metadata chars anyway. ✅
  • Unguarded event_logger.log / metrics → both wrapped in try/except; the in-flight tool exception still propagates. ✅
  • Hook-context contract, push_scopenew_scope, sync-hook warning → all applied. ✅
  • Also confirmed: the __cause__ unwrap is correct, and _mcp_call_id_var is safe (MCP dispatches each message in its own task, so no stale-id leak).

One Medium remains, plus a few Lows.

🟡 Medium — OAuth redirect still swallowed on the unsaved-chart (form_data_key) path
The OAuth-routing fix was applied to the saved-chart path (the except (OAuth2RedirectError, OAuth2Error): raise at get_chart_data.py:896, ahead of the generic handler — correct, and covered by the new TestOAuthErrorRouting). But the sibling _query_from_form_data (reached via the form_data_key/no-identifier branch at :381) runs its own command.run() and its only handler is except (CommandException, SupersetException, ValueError) at :1100, with no OAuth re-raise. Since OAuth2RedirectError/OAuth2Error subclass SupersetException, an OAuth error on this path is returned as a generic error_type="DataError" and never reaches the outer OAuth handlers (:917/:930) — so an LLM client querying an unsaved chart against a DB that needs re-auth gets a generic error instead of the OAUTH2_REDIRECT it needs. The new tests only exercise the saved path, so they give false confidence that "query-time OAuth errors reach the dedicated handlers." Line 381 is inside the outer try (:330), so mirroring the saved-path re-raise before :1100 is sufficient (both names are already imported at :36):

    except (OAuth2RedirectError, OAuth2Error):
        # OAuth errors subclass SupersetException; re-raise so the caller's
        # outer OAuth handlers return the redirect instead of a generic DataError.
        raise
    except (CommandException, SupersetException, ValueError) as e:
        ...

Worth folding in before merge, with a regression test through the form_data_key path.

🟢 Low — Last-resort handler can itself raise via str(exc)
StructuredContentStripperMiddleware.on_call_tool's final except — the documented "true last-resort capture point" that must never propagate — builds its message with _sanitize_error_for_logging(e) and f"Error: {e}" (both call str(e)) unguarded. A pathological __str__/__repr__ would make this handler raise past the middleware chain, the exact "encoding without a string argument" failure it exists to prevent. Rare (needs a hostile __str__), hence Low. Fix: wrap the format/sanitize in a try/except falling back to type(e).__name__.

🟢 Low — Structured error responses all bucket into .error (design note)
Tools that return a ChartError-style schema (not_found, NoData, EmptyQuery, bad params…) hit raised_is_user_error is None.error. Since that's the primary error channel for these tools, expected/user-caused outcomes inflate mcp.tool.*.error, which makes it hard to use for system-error alerting; .warning only ever gets raised user-class exceptions. The code comment calls this an intentional tradeoff — flagging so it's a conscious one. The parsed error_type is already available, so mapping a curated set of user-class types to .warning (or a separate .structured_error counter) would make .error mean "system failure."

🟢 Low — Test gaps
The metrics/event-logger guard tests assert the exception isn't masked but never assert the swallowed error is logged (logger.warning("Failed to emit MCP tool metrics…")), so a silent-swallow regression would pass. There's also no chain-level assertion for the structured-error .error bucket, and the hostile-name rejection is only exercised via the non-awaitable-MagicMock regex branch rather than a reachable registry returning None. All minor.

🟢 Low — Doc nits
The PRODUCTION.md Sentry example forwards the raw exception (capture_exception(error)) with no before_send scrubber — i.e. it doesn't practice the "raw exception may contain connection strings/tokens" caveat the docstring just added. And the hook-context docs single out user_id/duration_ms as possibly None, but mcp_call_id (and tool_name"unknown") can also be None for a system error on a non-tool message.

Re-reviewed with a multi-agent adversarial pass over the fixes + a fresh sweep of the reworked code, tests, and docs.

@rebenitez1802 rebenitez1802 left a comment

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.

Approving — the blocking items from my first review (metric double-count, injectable metric key, unguarded finally) are all verified fixed.

Non-blocking follow-ups: the OAuth redirect on the unsaved-chart (form_data_key) path is still swallowed (see my round-2 comment — a 3-line mirror of the saved-path fix + a test), plus the low nits. Happy for those to land here or as a quick follow-up. Nice work.

@bito-code-review

bito-code-review Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Code Review Agent Run #49cc52

Actionable Suggestions - 0
Review Details
  • Files reviewed - 3 · Commit Range: 46c2332..00a4944
    • superset/mcp_service/middleware.py
    • tests/unit_tests/mcp_service/chart/tool/test_get_chart_data.py
    • tests/unit_tests/mcp_service/test_middleware_logging.py
  • Files skipped - 0
  • Tools
    • MyPy (Static Code Analysis) - ✔︎ Successful
    • Astral Ruff (Static Code Analysis) - ✔︎ Successful
    • Whispers (Secret Scanner) - ✔︎ Successful
    • Detect-secrets (Secret Scanner) - ✔︎ Successful

Bito Usage Guide

Commands

Type the following command in the pull request comment and save the comment.

  • /review - Manually triggers a full AI review.

  • /pause - Pauses automatic reviews on this pull request.

  • /resume - Resumes automatic reviews.

  • /resolve - Marks all Bito-posted review comments as resolved.

  • /abort - Cancels all in-progress reviews.

Refer to the documentation for additional commands.

Configuration

This repository uses Superset You can customize the agent settings here or contact your Bito workspace admin at evan@preset.io.

Documentation & Help

AI Code Review powered by Bito Logo

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

change:backend Requires changing the backend infra:logging Infra setup - logging size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants