Skip to content

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

Open
goingforstudying-ctrl wants to merge 4 commits into
apache:masterfrom
goingforstudying-ctrl:fix/mcp-concurrent-session-scope
Open

fix(mcp): give concurrent tool calls isolated db sessions#42629
goingforstudying-ctrl wants to merge 4 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 on lines 1050 to 1051
if has_request_context():
return nullcontext()

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: Request-backed MCP calls bypass _mcp_tool_call_context() and therefore never set _mcp_session_token. When concurrent async requests execute on the same greenlet, their db.session lookups still use the shared greenlet key, so one request's teardown can remove the session used by another and detach its ORM objects. Apply the per-call session token to this path as well, while preserving the externally supplied g.user context. [race condition]

Severity Level: Critical 🚨
- ❌ Concurrent request-backed MCP tools can lose ORM session state.
- ❌ Long-running tools may fail after another request completes.
- ⚠️ Affected deployments include streamable HTTP with external request middleware.

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/auth.py
**Line:** 1050:1051
**Comment:**
	*Race Condition: Request-backed MCP calls bypass `_mcp_tool_call_context()` and therefore never set `_mcp_session_token`. When concurrent async requests execute on the same greenlet, their `db.session` lookups still use the shared greenlet key, so one request's teardown can remove the session used by another and detach its ORM objects. Apply the per-call session token to this path as well, while preserving the externally supplied `g.user` context.

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
👍 | 👎

@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 56.09756% with 18 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.42%. Comparing base (7b351d5) to head (f3bd3f7).
⚠️ Report is 28 commits behind head on master.

Files with missing lines Patch % Lines
superset/mcp_service/auth.py 33.33% 10 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   65.44%   65.42%   -0.02%     
==========================================
  Files        2810     2811       +1     
  Lines      159362   159455      +93     
  Branches    36372    36381       +9     
==========================================
+ Hits       104294   104324      +30     
- Misses      53026    53087      +61     
- Partials     2042     2044       +2     
Flag Coverage Δ
hive 38.08% <56.09%> (-0.01%) ⬇️
mysql 57.79% <56.09%> (-0.03%) ⬇️
postgres 57.84% <56.09%> (-0.04%) ⬇️
presto 39.97% <56.09%> (-0.02%) ⬇️
python 59.22% <56.09%> (-0.04%) ⬇️
sqlite 57.46% <56.09%> (-0.03%) ⬇️
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 3571d45
🔍 Latest deploy log https://app.netlify.com/projects/superset-docs-preview/deploys/6a6cce0e713bfe000838970c
😎 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.


# Give each tool call its own SQLAlchemy session; the greenlet-scoped
# default lets concurrent async calls share (and tear down) one session.
install_mcp_session_scoping()

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 factory-configured server path in server.py calls create_mcp_app(**factory_config) directly and never reaches init_fastmcp_server(). Consequently, deployments using use_factory_config=True do not install the MCP-aware scope function, so _mcp_tool_call_context() still creates per-call tokens that db.session ignores and concurrent tool calls continue sharing the greenlet-scoped session. Install the scoping during every MCP server creation path, including the factory path. [api mismatch]

Severity Level: Major ⚠️
- ❌ Factory-configured concurrent tools can share SQLAlchemy sessions.
- ❌ One call teardown can detach another call’s ORM instances.
- ⚠️ Default MCP server initialization is not affected.

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/app.py
**Line:** 1016:1016
**Comment:**
	*Api Mismatch: The factory-configured server path in `server.py` calls `create_mcp_app(**factory_config)` directly and never reaches `init_fastmcp_server()`. Consequently, deployments using `use_factory_config=True` do not install the MCP-aware scope function, so `_mcp_tool_call_context()` still creates per-call tokens that `db.session` ignores and concurrent tool calls continue sharing the greenlet-scoped session. Install the scoping during every MCP server creation path, including the factory path.

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
👍 | 👎

@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 on lines 967 to +977
mcp_instance = create_mcp_app(**factory_config)
# The factory path bypasses init_fastmcp_server(), so install the
# per-tool-call session scoping here as well; without it concurrent
# tool calls share the greenlet-scoped db.session.
# Lazy import mirrors init_fastmcp_server() to avoid the circular
# import through superset.extensions during startup.
from superset.mcp_service.session_scope import ( # noqa: PLC0415
install_mcp_session_scoping,
)

install_mcp_session_scoping()

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: Session scoping is installed only in run_server() after the factory returns. Callers that use the public create_mcp_app() factory directly receive a FastMCP instance whose protected tools still execute with the original greenlet-scoped db.session, so concurrent calls through that supported factory path retain the DetachedInstanceError/session-sharing failure. Install the MCP session scope as part of factory initialization or otherwise guarantee it before the returned instance can serve requests. [api mismatch]

Severity Level: Critical 🚨
- ❌ Direct factory deployments lack concurrent session isolation.
- ❌ Concurrent tool calls can produce detached ORM instances.
- ⚠️ Public factory behavior differs from `run_server()`.

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/server.py
**Line:** 967:977
**Comment:**
	*Api Mismatch: Session scoping is installed only in `run_server()` after the factory returns. Callers that use the public `create_mcp_app()` factory directly receive a FastMCP instance whose protected tools still execute with the original greenlet-scoped `db.session`, so concurrent calls through that supported factory path retain the DetachedInstanceError/session-sharing failure. Install the MCP session scope as part of factory initialization or otherwise guarantee it before the returned instance can serve requests.

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
👍 | 👎

@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

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

Labels

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

2 participants