Skip to content

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

Closed

Description

@verdier

Bug description

Concurrent MCP tool calls share a single SQLAlchemy Session, and each call's Flask app-context teardown removes that shared session while the other calls are still using it. Every in-flight call then holds detached instances, and the next attribute read raises DetachedInstanceError.

This is the mechanism behind #42567. That issue reports one symptom (generate_chart returning an error for a chart it has already committed) and #42621 removes that symptom, but the cause below is untouched and can surface in any tool.

The chain, in three steps:

  1. mcp_auth_hook runs every tool call inside _get_app_context_manager() (auth.py#L1026, used at L1100), which pushes a new app context per call. The docstring says why: so concurrent calls do not share one g namespace. That part works — I measured 20 distinct app contexts for 20 concurrent calls.

  2. db.session is not scoped to the app context. Superset pins flask-sqlalchemy==2.5.1, whose create_scoped_session defaults to scopefunc=_ident_func, and _ident_func is greenlet.getcurrent. Async tool calls are asyncio tasks, not greenlets: they all run in the same greenlet on the event-loop thread, so they all resolve to the same Session object.

  3. flask-sqlalchemy registers @app.teardown_appcontext def shutdown_session(...) which calls self.session.remove() unconditionally. So the first call to finish pops its own app context and removes the session shared by every call still running.

The existing comment at auth.py#L1141-L1146 already notes that db.session is "scoped by thread (not ContextVar)" and clears it before sync tool calls, where thread-pool workers do give each call its own session. The async path has no equivalent isolation, and one context per call actively makes it worse: more contexts means more teardowns landing on the shared session.

Evidence

apache/superset:6.1.0-py311, superset mcp run, stateless_http=True, stock pool, N concurrent generate_chart calls with save_chart=true. I instrumented the auth hook to log the session identity per call, and wrapped scoped_session.remove to log its caller.

Ten concurrent calls, ten distinct app contexts, one session:

[PROBE-ENTER] tool=generate_chart session=133425676349584 greenlet=... thread=... appctx=133425179069648
[PROBE-ENTER] tool=generate_chart session=133425676349584 greenlet=... thread=... appctx=133425183374416
[PROBE-ENTER] tool=generate_chart session=133425676349584 greenlet=... thread=... appctx=133425164314640
... (10 lines, same session id, same greenlet, same thread, 10 different appctx ids)

Then, as calls start finishing, the teardown removes it under the others:

[PROBE-REMOVE] session=133425676349584 caller="flask/ctx.py", line 255, in pop | self.app.do_teardown_appcontext(exc)
[PROBE-REMOVE] session=133425670911568 caller="flask/ctx.py", line 255, in pop | self.app.do_teardown_appcontext(exc)
...
sqlalchemy.orm.exc.DetachedInstanceError: Instance <Slice ...> is not bound to a Session
sqlalchemy.orm.exc.DetachedInstanceError: Instance <Slice ...> is not bound to a Session

Note the session ids differ between remove() calls: once the first teardown wipes the registry entry, the next database access creates a fresh session, which the next teardown wipes in turn — while the same calls are still running.

Impact

Not specific to charts, and not specific to the post-commit path:

Measured on stock 6.1.0, two bearer identities, asserted against rows in the metadata database:

concurrency successful responses DetachedInstanceError rows written
5 5/5 0 5
10 2/10 8 10
20 5/20 15 20
40 5/40 35 40

Every row was written. The database work succeeds; the responses report failure. For an agent client that is worse than a plain error — the usual reaction is a retry, which duplicates the object.

Tools that re-query their ORM objects by id inside their own session survive this and give a false green when used as a concurrency test — generate_dashboard is the example, it re-queries deliberately (generate_dashboard.py#L285).

How to reproduce

  1. Run superset mcp run (6.1.0 or master), JWT auth, stateless_http=True.
  2. Fire N concurrent generate_chart calls with save_chart=true against a valid dataset.
  3. Assert on rows in slices, not on the response envelope: from N=5 upward, rows are created that the client is told failed.

Happy to share the harness.

Possible directions

I did not want to guess a fix for something this structural, so, options as I see them:

  • Scope the session per asyncio task. flask-sqlalchemy 2.5.1 accepts session_options={"scopefunc": ...}; a scope function returning the current asyncio task (falling back to greenlet/thread outside a loop) would give each concurrent tool call its own session, and each teardown would only remove its own. This touches the whole app's session scoping, not just MCP, so it needs care on the web tier.
  • Run async tool bodies on a worker thread, as the sync path already effectively does. Thread scoping then does the isolation, at the cost of a thread per concurrent call.
  • Do not push a per-call app context and instead isolate g some other way, so that a teardown never fires while other calls are in flight. This trades one race for the other unless the session is also isolated, so it probably only works combined with the first option.

I'd rather implement whichever direction maintainers prefer than push one unilaterally. Happy to do the work and measure it on the same harness.

Screenshots/recordings

No response

Superset version

master / latest-dev

Python version

3.11

Node version

I don't know

Browser

Not applicable

Additional context

Related: #42567 (symptom in generate_chart), #42621 (removes that symptom, does not address this).

Checklist

  • I have searched Superset docs and Slack and didn't find a solution to my problem.
  • I have searched the GitHub issue tracker and didn't find a similar bug report.
  • I have checked Superset's logs for errors and if I found a relevant Python stacktrace, I included it here as text or in a screenshot.

Activity

  1. rusackas commented on Aug 8, 2026

    @rusackas
    Member

    Scoping the session per asyncio task sounds like the least invasive of the three options, since it mirrors what the thread pool already does for sync calls. Would welcome a PR on that if you want to take it on.

  2. verdier commented on Aug 10, 2026

    @verdier
    ContributorAuthor

    @rusackas took it on: #43007.

    Scoped per asyncio task, with the greenlet identity kept off the event loop so the web tier, Celery and the MCP thread pool are untouched. Measured on the harness from this issue — concurrency 10: 2/10 with 8 DetachedInstanceError before, 10/10 after; concurrency 20: 6/20 before, 20/20 after. A probe on the auth hook shows one session handed out for ten concurrent calls before the change, ten after.

    One consequence worth your eye, called out in the PR: sessions are no longer shared, so N concurrent calls now hold N connections. With the stock pool a burst of 20 exhausts it, and since the tool bodies do blocking DB work on the event loop, the wait blocks the loop instead of yielding — sizing the pool for the intended concurrency is enough. Whether the MCP server should also bound its own concurrency is a separate call; happy to fold either into the PR.

  3. verdier commented on Aug 11, 2026

    @verdier
    ContributorAuthor

    Some deployment context on this one, since it shapes what the fix needs to be rather than just how it is written.

    We evaluated the built-in MCP for a product and measured the concurrency ceiling before doing anything else: it broke above 2 concurrent generate_chart calls, which is what led to #42567 and this issue. We did not adopt it. Our MCP server talks to the REST API instead, so each tool call is an ordinary HTTP request served by the web tier — one request, one thread, one session, concurrency bounded by the server. Not an architectural preference: the web tier already has a concurrency model that works, and we needed one.

    That is the whole point I want to offer here, and it is not a suggestion to put the MCP behind HTTP — the in-process design buys direct access to commands, RBAC and progress streaming, and undoing it is a SIP, not a bug fix. The point is narrower: what made the difference for us was not session scoping, it was the execution model. A tool call that holds a thread degrades gracefully under load; a tool call that holds the event loop does not.

    Which is why, of the two follow-ups on the table, I would put the weight on the worker-thread offload rather than the semaphore. The semaphore (now in #43007, measured: 40/40 on the stock pool where isolation alone never finished) caps the symptom — it repairs availability by refusing load. The offload restores actual concurrency, and it is exactly what the HTTP path gets for free. Related: in generate_chart all 34 awaits are on ctx.*; the tools are async to report progress, not to yield the loop.

    We are contributing here because we would like to move onto the built-in MCP rather than around it. That is the only reason this feedback exists — when the execution model holds under concurrency, the REST detour stops being worth its cost for us.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    #bug:regressionBugs that are identified as regessions

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions