Skip to content

fix(mcp): never block a committed update_chart write as ToolError - #44386

Merged
aminghadersohi merged 12 commits into
apache:masterfrom
aminghadersohi:aminghadersohi/mcp-update-chart-response-size-guard
Sep 21, 2026
Merged

aminghadersohi merged 12 commits into
apache:masterfrom
aminghadersohi:aminghadersohi/mcp-update-chart-response-size-guard

Conversation

@aminghadersohi

@aminghadersohi aminghadersohi commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

SUMMARY

update_chart persists its chart write and only afterward does the MCP
response-size guard estimate the serialized result. When that response
exceeds the configured token limit, the guard raised ToolError, so a
successful chart update was reported to the calling agent as a failed tool
call -- a common outcome for charts with a non-trivial form_data. Agents
routinely retry on tool failure, replaying a call that already went
through.

The guard's truncation was previously limited to two closed sets:
INFO_TOOLS (read-only single-object tools) and DATA_QUERY_TOOLS
(row-based results). Mutating tools were excluded from both, so any
response over budget was hard-blocked regardless of whether the underlying
write had already happened.

This PR:

  • Adds a COMMITTED_WRITE_TOOLS set (currently update_chart) that is
    truncated the same way as INFO_TOOLS, but with the tool's identifying
    field (chart) protected from the two destructive truncation phases
    (large-dict summarization and the final "clear everything" phase), so a
    caller can always confirm what was written.
  • Adds a last-resort minimal-success fallback for COMMITTED_WRITE_TOOLS,
    used only if truncation still can't fit the response under budget. Unlike
    every other tool category, this path never raises ToolError. It also
    degrades the identifying field itself (to just id/uuid/slice_name/
    url) if that field alone is what's oversized, and correctly re-wraps a
    ToolResult whose payload couldn't be parsed as JSON instead of handing
    FastMCP a bare dict it can't encode.
  • Generalizes the existing CSV-export bisection helper into a reusable
    named-string-field truncator, and uses it for get_chart_sql, whose
    oversized responses are dominated by one large rendered-SQL string with
    no limit/row_limit parameter that could shrink it. Previously such
    responses were hard-blocked with an unactionable Reduction needed: ~0%
    message; now the SQL is bisected to the largest prefix that fits.
  • Bisecting SQL mid-statement can leave a prefix that is still valid,
    executable SQL -- e.g. a statement cut before its WHERE/LIMIT clause
    would silently scan far more data than the original query. The kept
    prefix now gets an in-band marker designed to make the truncated
    statement a syntax error regardless of where the cut landed (mid-comment,
    mid-string-literal, or mid-expression), verified against both sqlite3
    and sqlglot across several SQL dialects. _response_truncated /
    _truncation_notes remain the authoritative signal that the SQL is
    partial; the marker is defense in depth.

BEFORE/AFTER SCREENSHOTS OR ANIMATED GIF

N/A (backend-only change, no UI)

TESTING INSTRUCTIONS

  • pytest tests/unit_tests/mcp_service/test_middleware.py tests/unit_tests/mcp_service/utils/test_token_utils.py
  • test_update_chart_write_committed_before_size_check_still_succeeds
    reproduces the original bug (asserts the mutation happens, then fails
    against pre-fix code because ToolError was raised) and now asserts the
    guard returns a success response with a truncation marker instead.
  • Other new tests cover: the minimal-fallback path and its ToolResult
    re-wrapping, the chart field surviving both destructive truncation
    phases, the get_chart_sql string-field bisection, and the SQL marker
    rejecting every cut position across multiple real and simulated SQL
    engines.
  • Full tests/unit_tests/mcp_service/ suite passes unchanged (4313 passed,
    2 skipped).

ADDITIONAL INFORMATION

  • Has associated issue:
  • Required feature flags:
  • Changes UI
  • Includes DB Migration (follow approval process in SIP-59)
    • Migration is atomic, supports rollback & is backwards-compatible
    • Confirm DB migration upgrade and downgrade tested
    • Runtime estimates and downtime expectations provided
  • Introduces new feature or API
  • Removes existing feature or API

update_chart persists its write before the response-size guard checks the
serialized result, so an oversized response was reported to MCP clients as
a failed call even though the chart had already been updated -- a common
outcome that let retrying agents replay the same mutation.

Make committed-write tools (update_chart) truncation-eligible like the
existing INFO_TOOLS, protecting the tool's identifying field so a write
confirmation always survives even the most aggressive truncation phase,
and add a guaranteed-small fallback response for the (practically
unreachable) case where truncation still can't fit -- this path never
raises ToolError, since the mutation already committed.

Also generalize the CSV-export bisection helper to any named string field
and use it for get_chart_sql, whose oversized responses are dominated by
one large SQL string with no row/limit parameter to shrink it.
@codecov

codecov Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.22148% with 25 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.62%. Comparing base (a7dab4a) to head (83f1ebc).
⚠️ Report is 27 commits behind head on master.

Files with missing lines Patch % Lines
superset/mcp_service/middleware.py 80.55% 16 Missing and 5 partials ⚠️
superset/mcp_service/utils/token_utils.py 90.24% 2 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #44386      +/-   ##
==========================================
+ Coverage   80.57%   80.62%   +0.05%     
==========================================
  Files        2940     2942       +2     
  Lines      175316   175780     +464     
  Branches    40696    40795      +99     
==========================================
+ Hits       141253   141730     +477     
+ Misses      31398    31388      -10     
+ Partials     2665     2662       -3     
Flag Coverage Δ
hive 37.09% <0.00%> (-0.16%) ⬇️
mysql 56.29% <0.00%> (-0.22%) ⬇️
postgres 56.30% <0.00%> (-0.22%) ⬇️
presto 38.97% <0.00%> (-0.17%) ⬇️
python 84.97% <83.22%> (+0.09%) ⬆️
sqlite 56.02% <0.00%> (-0.22%) ⬇️
unit 76.79% <83.22%> (+0.17%) ⬆️

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.

…nable

Follow-up hardening on the response-size guard:

- Phase 4's dict summarizer ignored protected_keys, so a ChartInfo payload
  (more than the 20-key threshold) was replaced with a {_truncated,_message}
  marker before Phase 5 could protect it -- destroying the write confirmation
  in exactly the case the guard exists for.
- The minimal committed-write fallback returned a bare dict when a ToolResult's
  payload could not be parsed, which fails in FastMCP's to_mcp_result() and
  surfaces a completed write as an internal error anyway. Rewrap on
  isinstance(response, ToolResult) instead of on successful extraction.
- That fallback also claimed the write "already committed successfully", but
  update_chart defaults to generate_preview=True and persists nothing. Reworded
  to state only what is true: the call was not rolled back by the size limit.
- The fallback copied chart from the untruncated payload and never re-checked
  size, so the "guaranteed-small" response could itself exceed the limit.
  It now degrades chart to identifying scalars.
- A bisected SQL prefix stayed syntactically valid, so a statement cut before
  its WHERE/LIMIT clause could be executed unfiltered and unbounded. The kept
  prefix now carries an in-band marker, reserved inside the token budget.
Second review pass on the response-size guard:

- The truncation marker was a `--` line comment, so a cut statement stayed
  valid and executable -- exactly the full-table-scan hazard it was meant to
  prevent. Use an unterminated /* block comment, which is a parse error in
  every dialect.
- The three _try_truncate_* helpers gated rewrapping on successful payload
  extraction, so a ToolResult whose text is not a JSON object had its own
  wrapper fields model_dump()ed and returned as a plain dict, failing in
  FastMCP's to_mcp_result(). They now decline to truncate an opaque
  ToolResult and let the caller fall through to its own fallback.
- test_untruncated_sql_gets_no_marker returned at the under-limit guard and
  never reached the branch it was named for. Split into a pass-through test
  and a direct _bisect_string_length test that does cover it.
The unterminated /* block comment was not the universal parse error it
claimed to be: SQLite treats a block comment as terminated at end of input
and runs the statement, so a get_chart_sql response truncated before its
WHERE clause was still executable against it -- the exact hazard the marker
exists to close. Verified against sqlite3 directly.

Use an unterminated string literal instead, which is a tokenizer error in
every dialect including SQLite, and add a test that asserts the truncated
SQL fails to parse across sqlite/mysql/postgres/trino/bigquery.
The bisect point is arbitrary, so it can land inside a comment or a string
literal, and the previous marker only handled a cut in normal state. A cut
landing inside a block comment swallowed the whole marker and SQLite ran the
truncated statement -- verified against sqlite3 directly.

The marker now ends any open line comment, closes an open block comment, and
opens an unterminated string literal. Tested against a real sqlite3 engine
(not just sqlglot, which rejects markers SQLite happily executes) for cuts
landing in normal, line-comment, block-comment and string-literal state,
across sqlite/mysql/postgres/trino/bigquery.

Documented the marker as defense in depth: _response_truncated and
_truncation_notes remain the authoritative signal that the SQL is partial.
The rationale comment said the marker "opens an unterminated string literal"
in every cut state. That is wrong for a cut landing mid-string: there the
quote terminates the in-progress literal and the */ is swallowed into it, so
rejection rests entirely on the trailing bare identifiers. Trimming the
marker to what the comment described would silently reintroduce the bug.

Also widen the test's dialect list to cover duckdb, snowflake and tsql, which
the comment previously claimed without guarding.
@aminghadersohi
aminghadersohi marked this pull request as ready for review September 17, 2026 11:40
@aminghadersohi
aminghadersohi marked this pull request as draft September 17, 2026 11:40
Comment thread superset/mcp_service/middleware.py Outdated
Comment thread superset/mcp_service/utils/token_utils.py Outdated
_shrink_minimal_response measured the response once, before reducing
chart to its identifying fields, and never again. That reduction only
reaches chart: error and explore_url are copied verbatim from the
untruncated payload, and the identifying slice_name/url are themselves
free-form strings. Any one of them could leave the 'minimal' response
far over budget -- a 40k-character error field produced a ~10k-token
confirmation against a 200-token limit.

Clip every free-form string to a fixed cap so the confirmation is
bounded by construction, and warn if even that still exceeds an
extremely small limit rather than silently returning it.

Also keep chart.is_unsaved_state among the retained identifying fields.
update_chart defaults to generate_preview=True, which caches a preview
and persists nothing, so that flag is the caller's only in-band way to
tell a preview from a persisted write -- shrinking should not be what
removes it.
The minimal write confirmation clipped `error` as if it were a string,
but `update_chart` returns `GenerateChartResponse`, whose `error` is a
`ChartGenerationError` -- a nested model that reaches the size guard as a
dict once the ToolResult payload is parsed. `_clip_string` returns dicts
unchanged, so the only shape the tools emit was never bounded: an error
carrying a large `query_info`, `details` or `validation_errors` still
pushed the "minimal" confirmation over the limit, which the commit's own
post-reduction re-check duly logged.

Reduce a structured error to clipped identifying scalars the same way
`chart` is reduced, dropping the unbounded containers, and correct the
docstring's account of which fields are bounded and why.
@aminghadersohi
aminghadersohi marked this pull request as ready for review September 17, 2026 18:05

@bito-code-review bito-code-review Bot 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.

Code Review Agent Run #26bf41

Actionable Suggestions - 3
  • superset/mcp_service/utils/token_utils.py - 1
  • tests/unit_tests/mcp_service/utils/test_token_utils.py - 2
Additional Suggestions - 6
  • superset/mcp_service/middleware.py - 3
    • Misleading error-fields comment · Line 84-87
      This comment asserts everything not kept on `MCPBaseError`/subclasses is an unbounded container, but `timestamp` (fixed-size datetime) and `help_url` (short string) are also dropped and are neither - verified against `error_schemas.py`. A maintainer auditing the field list against the schema hits this contradiction; reword so the drop rule for non-container fields is stated too.
    • Duplicated opaque-payload guard · Line 1240-1249
      The opaque-ToolResult guard added at 1240-1249 is duplicated verbatim in `_try_truncate_data_query_response` (1334-1343) and `_try_truncate_string_field_response` (1417-1426). Three identical copies of decline logic (extract, isinstance check, warning, return None) will drift independently — a future fix to one copy resurfaces the FastMCP `to_mcp_result()` failure on the missed path. Please hoist into one helper used by all three.
    • Tool-schema coupling in middleware · Line 1512-1521
      `_minimal_committed_write_response` hardcodes update_chart's payload shape (chart/error/explore_url/schema_version/api_version) inside the generic ResponseSizeGuardMiddleware, while COMMITTED_WRITE_TOOLS (token_utils.py:500) is an extensible registry. A second committed-write tool with a different schema would get a fabricated confirmation: chart=None, success defaulting to True. Worth making the kept fields tool-driven before the set grows.
  • superset/mcp_service/utils/token_utils.py - 3
    • Duplicates _is_under_limit helper · Line 1114-1114
      `_is_under_limit` (token_utils.py:692-696) already wraps exactly `estimate_token_count(utils_json.dumps(data)) <= token_limit` and is used by `truncate_oversized_response`; this new function re-inlines the expression. Reuse the helper so the budget check lives in one place; the local `utils_json` import at line 1105 then becomes unused and can be removed.
    • Response-normalization block duplicated · Line 1107-1112
      Lines 1107-1112 repeat the `model_dump()`/`dict`/else normalization already in `truncate_query_result` (lines 1052-1057) and `truncate_oversized_response` (line 734). A shared `_response_to_data()` helper would keep ToolResponse-handling rules in one place; the wrapper-handling subtleties documented at middleware.py:1417-1426 show how easily these copies drift.
    • Marker registry bypassed · Line 1077-1079
      The `sql` path in `truncate_string_field_response` sources its in-band marker from `_STRING_FIELD_TRUNCATION_MARKERS`, but this `csv_data` fallback hardcodes no marker. Behavior is identical today, yet a future `csv_data` entry in the registry would be silently ignored here. Consider consulting the registry for consistency: `suffix_marker=_STRING_FIELD_TRUNCATION_MARKERS.get("csv_data", "")`.
Review Details
  • Files reviewed - 4 · Commit Range: 14ead58..92a3ad3
    • superset/mcp_service/middleware.py
    • superset/mcp_service/utils/token_utils.py
    • tests/unit_tests/mcp_service/test_middleware.py
    • tests/unit_tests/mcp_service/utils/test_token_utils.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 an incremental AI Review.

  • /review full - 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

Comment thread superset/mcp_service/utils/token_utils.py Outdated
Comment thread tests/unit_tests/mcp_service/utils/test_token_utils.py
Comment thread tests/unit_tests/mcp_service/utils/test_token_utils.py
… tool

COMMITTED_WRITE_TOOLS stated a category invariant -- mutating tools whose
transaction commits before the size guard inspects the response -- but held
only update_chart. update_dashboard commits at update_dashboard.py:360 and
was absent, so an oversized UpdateDashboardResponse still hard-errored after
the dashboard was written, and a retrying client would replay the mutation.

Membership alone was not enough: _handle_oversized_response hardcoded
frozenset({"chart"}) as the protected field, and the minimal confirmation
copied a fixed chart-shaped key list. Both are now per-tool, driven by
COMMITTED_WRITE_SPECS: each entry names the resource noun, the container
fields that carry the write confirmation, and whether the response model
actually declares `success`. Responses whose identity is top-level scalars
(delete_chart's deleted_id, create_dataset's id) need no protected field --
no truncation phase drops a top-level scalar.

The minimal confirmation now selects from the payload's own keys instead of
a fixed list, so a dashboard response no longer gains a `chart`,
`explore_url` or `success` its schema never declares.

Tests assert the table covers every mutating tool except the three that
commit nothing to the metadata database, and check every spec against the
registered output schemas so the table cannot drift from the models.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@netlify

netlify Bot commented Sep 18, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit 83f1ebc
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6ab0cbd86242cb0008218f05
😎 Deploy Preview https://deploy-preview-44386--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.

Resolves a semantic conflict the drift guard caught in CI: master added the
mutating tool `apply_dashboard_filters`, which commits a dashboard permalink
before the size guard inspects the response, so it belongs in
COMMITTED_WRITE_SPECS. Its identity (dashboard_id, dashboard_url,
permalink_key) is top-level scalars, which no truncation phase drops, so it
needs no protected container field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@bito-code-review bito-code-review Bot 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.

Code Review Agent Run #4fe4d2

Actionable Suggestions - 1
  • superset/mcp_service/middleware.py - 1
Additional Suggestions - 2
  • tests/unit_tests/mcp_service/test_mcp_tool_registration.py - 1
    • Ambiguous if/elif condition binding · Line 686-690
      The if/elif chain mixes condition styles: line 686 binds the walrus and tests the resulting set's truthiness, while line 690 parenthesizes the test and applies `is not`, which reads like an identity check against a bound name. Naming both conditions (`missing`, `success_in_response`) makes each branch's test unambiguous. Behavior is correct today; this is clarity-only.
  • superset/mcp_service/middleware.py - 1
    • changed_fields list dropped · Line 1594-1600
      `_select_confirmation_fields` keeps identifying containers plus top-level scalars, but `changed_fields` on `UpdateDashboardResponse` is `List[str]` (dashboard/schemas.py:996), so the minimal path drops it while the normal path returns it. The docstring's claim that `changed_fields` is kept does not hold. Consider retaining short string lists so write confirmations stay consistent.
Filtered by Review Rules

Bito filtered these suggestions based on rules created automatically for your feedback. Manage rules.

  • superset/mcp_service/utils/token_utils.py - 1
Review Details
  • Files reviewed - 5 · Commit Range: 92a3ad3..c15c11f
    • superset/mcp_service/middleware.py
    • superset/mcp_service/utils/token_utils.py
    • tests/unit_tests/mcp_service/test_mcp_tool_registration.py
    • tests/unit_tests/mcp_service/test_middleware.py
    • tests/unit_tests/mcp_service/utils/test_token_utils.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 an incremental AI Review.

  • /review full - 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

Comment thread superset/mcp_service/middleware.py

@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.

Approve: careful, well-iterated fix — the write-already-committed problem is correctly modeled, the chart field is protected through both destructive truncation phases, the minimal fallback is bounded by construction, and the SQL truncation marker is genuinely robust (and correctly framed as defense-in-depth behind _response_truncated). No blocking issues. Two test-quality nits below, neither gating.

🟢 Low — Minimal-fallback tests pass by swallowing StopIteration, not by the mocked signal

The five tests at tests/unit_tests/mcp_service/test_middleware.py:761,806,868,921,997 patch estimate_response_tokens with side_effect=[600, 600], but the committed-write fallback path calls it up to four times (on_call_tool → _try_truncate_info_response post-check → two _fits() calls in _shrink_minimal_response). The 3rd/4th calls exhaust the list and raise StopIteration, which _fits's except Exception (middleware.py:1822) catches and treats as "does not fit" — so the shrink is driven by exception-swallowing, and the inline comment "Force every truncation phase to report still over budget" doesn't describe those calls. The assertions themselves are sound (the boundedness checks use the unpatched estimate_token_count), so behavior is verified correctly — but the setup is brittle: narrowing _fits to except (TypeError, ValueError) (a plausible cleanup, since a broad except is discouraged here) would make all five tests error out with StopIteration despite correct middleware. Fix: replace side_effect=[600, 600] with return_value=600 so every call returns a real over-budget value and the tests don't depend on _fits catching StopIteration; update the comment accordingly.

🟢 Low — Two of the three new opaque-ToolResult guard branches are untested

The PR adds the same if extracted is None and isinstance(response, ToolResult): return None guard to _try_truncate_info_response, _try_truncate_data_query_response (middleware.py:1334), and _try_truncate_string_field_response (middleware.py:1417). Only the info-tool branch is exercised: test_opaque_tool_result_is_blocked_not_returned_as_dict (get_dashboard_info) and test_committed_write_fallback_rewraps_unparseable_tool_result (update_chart) both route through _try_truncate_info_response. The get_chart_sql/string-field test and the data-query tests all pass parseable payloads, so the guards at :1334 and :1417 never execute — a regression that reintroduced model_dump() of the wrapper there would ship undetected. Fix: add one test feeding an unparseable ToolResult through a STRING_FIELD_TRUNCATION_TOOLS tool (get_chart_sql) and one through a DATA_QUERY_TOOLS tool, asserting a ToolError is raised (mirroring the existing info-tool test).

@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.

Approve — the response-size-guard rework is correct and well-tested. The committed-write invariant (transaction commits before the guard inspects the response) holds for all 21 COMMITTED_WRITE_SPECS entries, verified against the actual command/tool code; the minimal fallback is provably bounded whichever field was oversized; the SQL truncation marker holds up across cut-states and dialects; and the truncated response bypasses output-schema validation (via the CallToolResult/meta path), so it can't be re-rejected. No security boundary is affected — the guard runs post-RBAC and post-execution and only clips data already bound for the same caller.

Two non-blocking nits:

🟢 Minimal-response tests drive the shrink via a swallowed StopIteration, not the mocked estimate. In the four plain-dict committed-write tests, estimate_response_tokens is patched with side_effect=[600, 600], but both values are consumed by on_call_tool and the info-truncation re-check before _shrink_minimal_response runs. Its _fits calls then hit the exhausted iterator and raise StopIteration, which _fits's except Exception: return False swallows — so the shrink runs because the iterator ran out, not because the mock reported over-budget, and the _fits early-return-True branch is never covered. Not a production defect (the code is correct, and each test still independently asserts estimate_token_count(...) <= 500), but brittle: narrowing that except would break the tests with no behavior change. Consider a callable side_effect that returns 600 for any call, plus one case where the minimal payload already fits.

🟢 success: True is synthesized for an unparseable payload. _select_confirmation_fields does minimal.setdefault("success", True) when reports_success, so an unparseable payload ({}) yields success: True. Essentially unreachable in practice (these tools always serialize to a JSON object), and harmless even if reached since the write did commit under the caller's authorization — worth just a one-line comment noting the assumption.

@aminghadersohi

Copy link
Copy Markdown
Contributor Author

@rebenitez1802 Thanks for both approval reviews! Addressed the three nits in 5763594: over-budget mocks now return 600 on every call, with corrected comments and an already-fits preservation test; opaque ToolResult coverage now includes get_chart_sql and execute_sql, asserting ToolError without dumping the wrapper; and a one-line comment documents the success fallback assumption. All 239 affected tests and branch-scoped pre-commit checks pass. No review re-requested.

@bito-code-review

bito-code-review Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Agent Run #eb75db

Actionable Suggestions - 0
Review Details
  • Files reviewed - 2 · Commit Range: c15c11f..5763594
    • superset/mcp_service/middleware.py
    • tests/unit_tests/mcp_service/test_middleware.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 an incremental AI Review.

  • /review full - 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

@github-actions github-actions Bot added the doc Namespace | Anything related to documentation label Sep 21, 2026
@aminghadersohi

Copy link
Copy Markdown
Contributor Author

@rebenitez1802 Following up on both review bodies (21:19 review, 21:33 review): the swallowed-StopIteration concern is valid test-quality feedback, not cosmetic.

The non-exhausting over-budget mocks, already-fits preservation case, opaque get_chart_sql/execute_sql coverage, and success-assumption comment were added in 5763594. Closing the verification gap in 83f1ebc: added parameterized shrink tests using the real estimator for oversized chart identity, structured errors, and URLs. These verify actual over-budget inputs become bounded while preserving identity, preview state, and failure status.

Deliberate-break evidence (all temporary mutations restored):

  • Disabled _shrink_minimal_response: 5 failed, 1 passed, including the existing oversized-field and structured-error tests plus all three real-estimator cases.
  • Removed the _fits early return: 1 failed, the already-fits preservation test.
  • Narrowed _fits's exception handler to (TypeError, ValueError): 18 passed, confirming the minimal-response tests do not depend on swallowing StopIteration.
  • Restored production code: 251 affected tests passed; staged and branch-scoped pre-commit run gates and explicit pre-commit run mypy passed.

Both review bodies are addressed; GitHub has no body-level resolve operation or associated unresolved thread, so this linked reply records closure. I have not re-requested review or dismissed either approval.

Acceptance criteria remain disclosed: AC 3 is PARTIAL (oversized-response truncation is handled, but update_chart's default payload is not slimmed); AC 6 remains post-deploy Datadog verification. Neither is being marked complete.

@aminghadersohi

Copy link
Copy Markdown
Contributor Author

Reply to Bito's 2026-09-17 review body, “Marker registry bypassed”: valid consistency finding, addressed in 83f1ebc.

truncate_query_result's CSV fallback now passes suffix_marker=_STRING_FIELD_TRUNCATION_MARKERS.get("csv_data", "") to _truncate_named_string_field, matching truncate_string_field_response. There is no default CSV marker change. The CSV regression test covers both an empty registry marker and a configured non-empty marker, asserting the latter survives truncation and the complete response stays within its token budget.

Affected tests: 251 passed. Staged/branch-scoped pre-commit and explicit mypy passed. This is body-only feedback with no associated unresolved thread; this linked reply records it as addressed.

@aminghadersohi

Copy link
Copy Markdown
Contributor Author

Reply to Bito's 2026-09-18 review body, “changed_fields list dropped”: valid, addressed in 83f1ebc.

UpdateDashboardResponse.changed_fields is a string list, and _select_confirmation_fields previously dropped it despite the docstring's claim. The selector now retains string lists only when bounded by both 20 items and 200 total characters. Oversized, mixed-type, and nested lists remain omitted. The docstrings and MCP configuration documentation describe this accurately; regression tests cover empty/short lists, exact bounds, each exceeded bound, nested/mixed values, and a dashboard minimal fallback retaining ["css"].

Affected tests: 251 passed. Staged/branch-scoped pre-commit and explicit mypy passed. This is body-only feedback with no associated unresolved thread; this linked reply records it as addressed. The separate restore/delete identity finding remains disproved and closed; it is not being reopened. AC 3 remains PARTIAL and AC 6 remains post-deploy verification.

@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.

LGTM

@bito-code-review

bito-code-review Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Agent Run #47bd8a

Actionable Suggestions - 0
Additional Suggestions - 4
  • tests/unit_tests/mcp_service/test_middleware.py - 3
    • Inline imports violate rule 12745 · Line 829-832
      These inline `from superset.mcp_service.utils.token_utils import ...` statements duplicate the module-level import of `estimate_token_count` from the same package (line 50). `token_utils` is already imported at module scope, so no circular dependency justifies the inline imports, and they carry no explanatory comment. Per repo rule 12745, move `COMMITTED_WRITE_SPECS` and `estimate_response_tokens` into the top-level import block and drop both function-body imports.
    • Unannotated test locals rule 13153 · Line 834-834
      The new locals `middleware` (line 834) and `spec` (line 896) are unannotated, while the same diff annotates `minimal: dict[str, Any]` (line 835) and the `oversized_field`/`changed_fields` parameters. Repo rules 13153/12787 require explicit annotations on test locals including mocks. The file's 48 pre-existing `middleware = ResponseSizeGuardMiddleware(...)` assignments are unannotated, so this matches the file baseline — flagging once for the new lines per the org rule, not as a file-wide demand.
    • Indirect mock call-count assertion · Line 861-861
      `estimate` is the `patch(...)` context manager; its `.call_count` proxies the wrapped `estimate_response_tokens`, so the assertion works, but reading it as `estimate.call_count == 2` invites confusion with the real function. Asserting on `estimate_response_tokens.call_count` (which `patch` rebinds to the mock) states the same expectation through the name under test.
  • docs/admin_docs/configuration/mcp-server.mdx - 1
    • Doc contradicts fallback scope · Line 691-691
      The claim that the fallback 'does not slim update_chart's default payload' doesn't match the code: `update_chart` is in `COMMITTED_WRITE_SPECS` (token_utils.py:577) and `_handle_oversized_response` applies `_minimal_committed_write_response` to every `COMMITTED_WRITE_TOOL` by name (middleware.py:1753-1758), with no preview-vs-persist distinction. An oversized default (generate_preview=True) response would be slimmed. Consider rewording to avoid misleading readers.
Review Details
  • Files reviewed - 5 · Commit Range: 5763594..83f1ebc
    • docs/admin_docs/configuration/mcp-server.mdx
    • superset/mcp_service/middleware.py
    • superset/mcp_service/utils/token_utils.py
    • tests/unit_tests/mcp_service/test_middleware.py
    • tests/unit_tests/mcp_service/utils/test_token_utils.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 an incremental AI Review.

  • /review full - 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

@aminghadersohi
aminghadersohi merged commit 8b9b055 into apache:master Sep 21, 2026
76 checks passed
@gabotorresruiz

Copy link
Copy Markdown
Contributor

@aminghadersohi Good fix, and the regression test earns its place. I checked out 83f1ebc and ran the new tests against the merge base a7dab4a: 24 of them fail there, including test_update_chart_write_committed_before_size_check_still_succeeds with Response blocked for update_chart: ~1049 tokens exceeds limit of 500. The full tests/unit_tests/mcp_service/ tree passes on the head (4511 passed, 1 skipped). Two follow ups, neither a reason to revert anything.

Not a blocker, but the invariant this PR establishes does not hold when MCP_STRUCTURED_OUTPUT_ENABLED=True

_rewrap_as_tool_result (middleware.py:1230) builds the replacement ToolResult from content alone, so structured_content comes back None. With MCP_STRUCTURED_OUTPUT_ENABLED=True the tool keeps its outputSchema in tools/list, and the SDK rejects the call on the client side at mcp/client/session.py:433.

I ran the shipped middleware stack (build_middleware_list(structured_output_enabled=...) plus the guard) against a tool returning a real GenerateChartResponse:

truncation,       structured off  ->  is_error=False   (shipped default, fine)
minimal fallback, structured off  ->  is_error=False   (shipped default, fine)
truncation,       structured on   ->  RuntimeError: Tool update_chart has an output schema but did not return structured content
minimal fallback, structured on   ->  RuntimeError: same

So with that flag on, a committed update_chart write still reaches the caller as a hard error, just from a different layer. This predates the PR (get_dashboard_info does the same thing at a7dab4a, I verified), but the committed write tools only started taking this path here. Setting structured_content=payload in that rewrap turns both structured on cases into is_error=False, and the payload still validates against the real output schema. Something like test_truncated_response_keeps_structured_content_when_structured_output_enabled would pin it.

Small one: error is bounded in the fallback but wiped in the path that runs first

_clip_error (middleware.py:1863) deliberately keeps error_type, message and details, and test_minimal_response_bounds_structured_error_object asserts exactly that. That test reaches the fallback by patching estimate_response_tokens to a constant 600. With the real estimator the response goes through _try_truncate_info_response first, error is not in spec.identifying_fields for update_chart, so Phase 5 _replace_collections_with_summaries (token_utils.py:741) sets it to {}, the result then fits, and _minimal_committed_write_response never runs.

At the shipped token_limit=25000, an update_chart failure carrying 3000 validation_errors entries (53065 estimated tokens) comes back as:

success = False
error   = {}
notes   = ["Field 'error' dict (4 keys) cleared to fit limit"]

success: False survives, so nobody reads it as a success, but error_type and message are gone. Phase 3 only clips long strings, so many short nested entries are exactly the shape that reaches Phase 5. Adding error to the protected keys at middleware.py:1726, or running _clip_error before Phase 5, would give the truncation path the guarantee the fallback already makes, and the same test without the estimator patch would lock it in.

Separately, and outside this PR: the size guard is not the only place a committed update_chart can be reported as failed. I forced a raise in each post commit step, and the handler at update_chart.py:1134 returns success: False, chart: None for a ValueError, KeyError or AttributeError raised after UpdateChartCommand.run() has committed, while a RuntimeError out of _get_chart_preview_internal escapes the preview except tuple entirely and surfaces as ToolError. Happy to open a follow up for that if you think it is worth one.

villebro pushed a commit that referenced this pull request Sep 22, 2026
…4386)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 8b9b055)
villebro added a commit that referenced this pull request Sep 22, 2026
…n 7.0

apply_dashboard_filters (#44096) was never backported to 7.0, but the
cherry-pick of #44386's COMMITTED_WRITE_SPECS snapshot brought in a
stale entry for it, failing
test_committed_write_specs_cover_every_committing_mutating_tool's
set-equality assertion against MUTATING_TOOLS.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

doc Namespace | Anything related to documentation size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants