Skip to content

fix(mcp): give concurrent tool calls isolated db sessions - #42629

Merged
sadpandajoe merged 8 commits into
apache:masterfrom
goingforstudying-ctrl:fix/mcp-concurrent-session-scope
Aug 13, 2026
Merged

sadpandajoe merged 8 commits into
apache:masterfrom
goingforstudying-ctrl:fix/mcp-concurrent-session-scope

Conversation

@goingforstudying-ctrl

Copy link
Copy Markdown
Contributor

SUMMARY

Ran into #42622 while looking at the MCP concurrency reports: fire two async tool calls at the same time and one of them can come back with DetachedInstanceError even though each call pushes its own app context. Dug into why, and the session scope is the culprit — db.session (flask-sqlalchemy 2.5.1) keys its registry by greenlet ident, and asyncio tasks on the event loop all live on the same greenlet, so N concurrent tool calls end up sharing ONE Session. Whichever call finishes first pops its app context, the teardown handler calls db.session.remove(), and every other in-flight call is suddenly holding detached instances. That's also what makes #42567's generate_chart report failure for a chart it had already committed, and why retrying agents end up creating duplicates.

The fix keys the registry on a per-call token instead. _get_app_context_manager() sets a fresh ContextVar token when it pushes the call's app context, and the MCP scopefunc resolves db.session from that token — each call gets its own Session, and the app-context teardown removes exactly that call's. Anywhere outside an MCP tool call the scopefunc falls back to the plain greenlet ident, so web, CLI, and Celery paths behave exactly as before. It's wired up from init_fastmcp_server() so every serving mode gets it.

The new tests include an interleaved two-task race that reproduces the shared-session teardown (fails without the fix, passes with it), plus a counterfactual that pins down the old behavior: with the greenlet scope restored, both calls really do resolve to the same Session and the first teardown removes it out from under the survivor. Not 100% sure the ContextVar-token approach is what you'd pick long-term vs. upgrading flask-sqlalchemy, but it's contained to the MCP service and doesn't move anything else.

TESTING INSTRUCTIONS

  • pytest tests/unit_tests/mcp_service/test_session_scope.py (5 new tests)
  • Ran the neighboring suites locally: test_g_user_race_condition, test_auth_*, test_middleware, test_rbac_tool_enforcement, the top-level mcp_service tests, and the full chart/ tool suite (~1900 tests total) — all green
  • pre-commit run (mypy, ruff, pylint) passes on the changed files

ADDITIONAL INFORMATION

db.session is scoped by greenlet ident, but async MCP tool calls are
asyncio tasks on one event-loop greenlet, so every in-flight call
resolves to the same Session. The first call to finish removes that
session at app-context teardown, detaching the other calls' instances
and surfacing DetachedInstanceError on the next attribute read (seen
in generate_chart failing for a chart it had already committed).

Scope the session registry to a per-call ContextVar token set while
_get_app_context_manager pushes the call's app context, so teardown
removes exactly that call's session. Outside MCP tool calls the scope
falls back to the greenlet ident, leaving web, CLI, and Celery paths
unchanged. Installed from init_fastmcp_server.
@bito-code-review

bito-code-review Bot commented Jul 31, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Agent Run #3bef32

Actionable Suggestions - 0
Review Details
  • Files reviewed - 4 · Commit Range: 24162b4..24162b4
    • superset/mcp_service/app.py
    • superset/mcp_service/auth.py
    • superset/mcp_service/session_scope.py
    • tests/unit_tests/mcp_service/test_session_scope.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

Comment thread superset/mcp_service/auth.py Outdated
@bito-code-review

Copy link
Copy Markdown
Contributor

The flagged issue is correct. The current implementation of MCP tool execution in the pull request already addresses this race condition by introducing superset/mcp_service/session_scope.py and updating superset/mcp_service/auth.py to use _mcp_tool_call_context(). This context manager ensures that each tool call is tagged with a unique _mcp_session_token via ContextVar, which forces db.session to create a separate SQLAlchemy session for each concurrent call, preventing the teardown of one request from detaching the ORM objects of another.

Since the fix is already implemented in the provided diff, no further action is required for this specific issue. I have checked the PR comments, and there are no other pending review comments to address.

@codecov

codecov Bot commented Jul 31, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 51.02041% with 24 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.62%. Comparing base (e177773) to head (ca772fc).
⚠️ Report is 5 commits behind head on master.

Files with missing lines Patch % Lines
superset/mcp_service/auth.py 30.43% 16 Missing ⚠️
superset/mcp_service/app.py 42.85% 4 Missing ⚠️
superset/mcp_service/server.py 0.00% 2 Missing ⚠️
superset/mcp_service/session_scope.py 88.23% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #42629      +/-   ##
==========================================
- Coverage   66.65%   66.62%   -0.03%     
==========================================
  Files        2866     2867       +1     
  Lines      162911   162890      -21     
  Branches    37530    37505      -25     
==========================================
- Hits       108584   108532      -52     
- Misses      52202    52231      +29     
- Partials     2125     2127       +2     
Flag Coverage Δ
hive 38.18% <51.02%> (+<0.01%) ⬆️
mysql 57.90% <51.02%> (-0.01%) ⬇️
postgres 57.94% <51.02%> (-0.01%) ⬇️
presto 40.13% <51.02%> (+<0.01%) ⬆️
python 59.33% <51.02%> (-0.01%) ⬇️
sqlite 57.57% <51.02%> (-0.01%) ⬇️
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.

The full unit-test run leaks a request context into the worker from an
earlier test (sql_lab_test.py pushes one and never pops it), so
_get_app_context_manager() takes its nullcontext branch and the per-call
session token is never set — the three scoping tests then fail on suite
ordering rather than on the code under test. Enter the contexts through
_mcp_tool_call_context() directly, which always sets the token and pushes
a fresh app context.
@netlify

netlify Bot commented Jul 31, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for superset-docs-preview ready!

Name Link
🔨 Latest commit ca772fc
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6a7dfe415646c400098593b3
😎 Deploy Preview https://deploy-preview-42629--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/app.py
@bito-code-review

bito-code-review Bot commented Jul 31, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Agent Run #de0d0e

Actionable Suggestions - 0
Review Details
  • Files reviewed - 1 · Commit Range: 24162b4..3571d45
    • tests/unit_tests/mcp_service/test_session_scope.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

run_server(use_factory_config=True) builds the app via create_mcp_app()
directly, bypassing init_fastmcp_server() where the per-call scoping is
installed. Concurrent tool calls in factory-configured deployments would
still share the greenlet-scoped db.session. Install the scoping in this
branch too.
@goingforstudying-ctrl

Copy link
Copy Markdown
Contributor Author

Good catch. The factory-configured path in run_server builds the app via create_mcp_app(**factory_config) and never went through init_fastmcp_server(), so the per-call session scoping was not installed there. Added the same install_mcp_session_scoping() call to the factory branch (with the lazy import to avoid the startup circular import). Factory deployments now get isolated sessions per tool call too.

Comment thread superset/mcp_service/server.py
@bito-code-review

bito-code-review Bot commented Jul 31, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Agent Run #7ca966

Actionable Suggestions - 0
Review Details
  • Files reviewed - 1 · Commit Range: 3571d45..d868997
    • superset/mcp_service/server.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

Callers that serve the app straight from the factory never pass
through init_fastmcp_server() or run_server(), so they kept the
greenlet-scoped default and the shared-session race came back on
that path. The lazy import is guarded for the module-import-time
case where superset.extensions is still mid-initialization; the
server entry points install the scopefunc there instead.
@goingforstudying-ctrl

Copy link
Copy Markdown
Contributor Author

Addressed in f3bd3f7. create_mcp_app() now installs the per-call session scoping itself, so deployments that serve the app directly from the factory get the same isolation as the init_fastmcp_server() and run_server() paths. The lazy import is guarded for the module-import-time case where superset.extensions is still mid-initialization, with the server entry points as the deterministic fallback. Added a test covering the factory-only path.

@bito-code-review

bito-code-review Bot commented Aug 1, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Agent Run #c1c099

Actionable Suggestions - 0
Review Details
  • Files reviewed - 2 · Commit Range: d868997..f3bd3f7
    • superset/mcp_service/app.py
    • tests/unit_tests/mcp_service/test_session_scope.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

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

Reviewed the session-isolation change at HEAD f3bd3f7. The mechanism is sound for the path it targets:

  • Concurrency model / scope key. Async tool calls are asyncio tasks multiplexed on one event-loop greenlet, so the stock greenlet-keyed scoped_session collapses them onto one Session. Keying the registry on a per-call ContextVar token (("mcp_tool_call", id(token))) is correct: each asyncio task copies its context, so _mcp_session_token.set(object()) yields a distinct token per concurrent call and two in-flight calls cannot resolve to the same key. Outside tool calls the scopefunc falls back to greenlet.getcurrent(), matching the flask-sqlalchemy default, so web/CLI/Celery paths are unchanged.
  • Teardown (success + exception). The token is reset in finally after the with app_context() block pops, so the app-context teardown's db.session.remove() still resolves to this call's key and removes exactly that session; the exception path exits the same with (plus _cleanup_session_on_error()). Ordering is right and no stale registry entry is left keyed by a to-be-reused id().
  • Identity binding. The token is set before the app context is pushed and _setup_user_context() runs inside it, so identity resolution and the subsequent queries share the same isolated session — no session-X-identity / session-Y-query drift.
  • Thread-safety. No new shared mutable registry is introduced; mcp_session_scopefunc only reads a ContextVar, and install_mcp_session_scoping() is a one-time idempotent scopefunc swap.
  • Wiring. Scoping is installed on all three entry paths (create_mcp_app, init_fastmcp_server, run_server) and the scope is entered per dispatch via _get_app_context_manager() in both the sync and async mcp_auth_hook wrappers.
  • Tests. test_concurrent_tool_calls_get_isolated_sessions proves isolation non-vacuously (real asyncio.gather, asserts distinct Session objects and that call A's session survives call B's teardown and still executes SQL); the counterfactual with the greenlet scopefunc restored demonstrates the collision the change prevents.

On the three open codeant-ai threads:

  • app.py:1037 / server.py:977 (factory path) — both flag that create_mcp_app(**factory_config) bypasses the install. Addressed at HEAD: create_mcp_app() now installs the scopefunc itself (app.py:707-726), in addition to init_fastmcp_server() and run_server(). These are moot and can be resolved.
  • auth.py:1051 (request-backed path) — valid but scoped. When has_request_context() is true the call takes the nullcontext() branch, no token is set, and concurrent request-backed calls on one greenlet again share the greenlet-keyed session. This is not a regression (it is the pre-existing behavior for that path, and the default OSS streamable-http transport pushes an app context rather than a Flask request context, so it takes the fixed path), but the isolation guarantee does not extend to deployments where external middleware pushes a per-request Flask context. Worth either extending the per-call token to that branch (while preserving the externally supplied g.user) or documenting the per-request-scoping requirement for request-backed deployments.

Nit (optional): keying on id(token) is safe here only because remove() clears the entry while the token is still referenced; keying the registry on the token object directly would remove even the theoretical id()-reuse concern at no cost.

CI is green across the suite.

@aminghadersohi

Copy link
Copy Markdown
Contributor

just pending the unresolved codeant comments

@rusackas

Copy link
Copy Markdown
Member

@goingforstudying-ctrl One thing before this can go in.

Codeant's thread on auth.py's nullcontext() branch is still open and it's seemingly accurate: when has_request_context() is true, _mcp_session_token never gets set, so request-backed calls (the WorkspaceContextMiddleware path) still share the greenlet-scoped session, which is the exact race this PR fixes everywhere else. The other two codeant threads got addressed in f3bd3f7 (thanks!), but this one hasn't (yet!).

When a request context is active (e.g. Preset's WorkspaceContextMiddleware
path), _get_app_context_manager() returned nullcontext() and never set
_mcp_session_token, so concurrent request-backed tool calls still shared
the greenlet-scoped session — the same race the rest of this change
eliminates. Reuse the request context but tag the call, and deregister
the per-call session on exit since no app-context teardown runs here.
@goingforstudying-ctrl

Copy link
Copy Markdown
Contributor Author

Good catch, thanks. The request-context branch now tags the call with a per-call session token as well (new _request_tool_call_context in auth.py), so request-backed calls get isolated sessions like every other path instead of sharing the greenlet-scoped one. Since no app context is pushed in that branch, the per-call session is deregistered explicitly on exit, leaving the request's own session to the normal request lifecycle.

Pushed as 9b73ab4, with a test covering concurrent tool calls under an active request context plus registry cleanup on exit. The full mcp_service unit test module passes locally.

@bito-code-review

bito-code-review Bot commented Aug 10, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Agent Run #82e3be

Actionable Suggestions - 0
Review Details
  • Files reviewed - 3 · Commit Range: f3bd3f7..9b73ab4
    • superset/mcp_service/auth.py
    • tests/unit_tests/mcp_service/test_auth_user_resolution.py
    • tests/unit_tests/mcp_service/test_session_scope.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

@verdier

verdier commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Hi @goingforstudying-ctrl — heads up so you hear it from me rather than find it: I opened #43007 for #42622 without having seen this PR, which predates mine by ten days and reaches the same diagnosis. @rusackas asked us to compare notes before either lands, so I backported your approach onto a Superset 6.1 harness and measured both under identical conditions. Full write-up is in this comment; the short version:

  • both fix it: 10/10 concurrent generate_chart calls succeed, ten distinct sessions for ten tasks, against 2/10 and one shared session on master
  • both hit the same cliff at pool_size + max_overflow: 15/16 at concurrency 16 with a QueuePool ... timeout 30.00, wall clock within run-to-run variance of each other

So the pool consequence is a property of isolating sessions at all, not of how — whichever lands needs a concurrency guard, and I will write one that applies to either.

Two things I flagged as costs of your approach, in case they are useful rather than annoying: the install in create_mcp_app sits in a try/except ImportError that only logs at debug, so a serving path missing all three install points keeps the bug silently; and ("mcp_tool_call", id(token)) uses a reusable address as the registry key, so an entry that is not removed could be inherited by a later call. I also flagged, on my own PR, that your _remove_session_safe() on the request-context path closes a registry leak that mine has and yours does not — if mine is the one that lands, I would borrow that with credit.

One caveat: backporting yours to 6.1 needed an import fix because 6.1 imports has_app_context inside mcp_auth_hook while master has it at module level. That is my backport, not your PR.

No stake in which one lands — happy to close mine and bring the measurements and the guard here if the maintainers prefer yours.

@rusackas

Copy link
Copy Markdown
Member

@goingforstudying-ctrl LGTM, thanks for chasing the request-context gap down, that thread's closed out now.

@verdier appreciate the head-to-head, that's exactly what I was hoping for before picking one. This one got here first and the threads are resolved, so I'm inclined to land this and have you bring the concurrency guard over as a follow-up. I'll close #43007 in favor of this, and thanks for opening it!

@rusackas rusackas added the merge-if-green If approved and tests are green, please go ahead and merge it for me label Aug 11, 2026
@sadpandajoe
sadpandajoe merged commit d0658ba into apache:master Aug 13, 2026
69 checks passed
@bito-code-review

Copy link
Copy Markdown
Contributor

Bito Automatic Review Skipped – PR Already Merged

Bito scheduled an automatic review for this pull request, but the review was skipped because this PR was merged before the review could be run.
No action is needed if you didn't intend to review it. To get a review, you can type /review in a comment and save it

justinpark pushed a commit that referenced this pull request Sep 23, 2026
Co-authored-by: goingforstudying-ctrl <goingforstudying-ctrl@users.noreply.github.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Joe Li <joe@preset.io>
(cherry picked from commit d0658ba)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-if-green If approved and tests are green, please go ahead and merge it for me size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MCP: concurrent tool calls share one SQLAlchemy session and remove it from under each other

5 participants