Skip to content

fix(api): reuse MCP client connections across tool invocations - #42658

Open
LetMeSleep8h wants to merge 4 commits into
langgenius:mainfrom
LetMeSleep8h:fix/reuse-mcp-client-connections
Open

LetMeSleep8h wants to merge 4 commits into
langgenius:mainfrom
LetMeSleep8h:fix/reuse-mcp-client-connections

Conversation

@LetMeSleep8h

@LetMeSleep8h LetMeSleep8h commented Sep 21, 2026

Copy link
Copy Markdown

Summary

Each MCPTool invocation opened a new MCP connection and closed it as soon as the call returned, so stateful MCP servers lost all session state between calls — e.g. with the official Playwright MCP server, browser_navigate succeeds but the next browser_snapshot lands on a fresh about:blank context.

This adds a process-wide MCPClientManager (api/core/mcp/client_manager.py) that pools successfully initialized clients, and routes MCPTool.invoke_remote_mcp_tool through it:

  • consecutive calls reuse the same session, so stateful servers keep their context;
  • the pool key is the stable execution scope — tenant, end user, provider config, server URL and timeouts — never the per-call credentials: forwarded-identity tokens are minted per call and OAuth tokens rotate, so keying on credentials would prevent all reuse, while the session they authenticated at initialize() keeps working and the pooled client refreshes its own token on 401. Distinct tenants, end users and provider configs get distinct connections, so a stateful server never shares session state across them;
  • locking is two-level and strictly ordered: the registry lock only guards the pool dict, and per-entry locks serialize calls on one connection (the sync ClientSession drives a single-worker executor). Entries are closed outside the registry lock — a busy entry is marked doomed and closed by its in-flight call on the way out, so a call hung on an unresponsive server (the session has no read timeout) can never wedge other keys;
  • entries are closed after an idle TTL (default 300s, MCP_CLIENT_POOL_IDLE_TTL) and the pool is capped (MCP_CLIENT_POOL_MAX_SIZE, default 100), evicting least-recently-used entries;
  • a pooled connection that died (MCPConnectionError) is evicted and the call retried once on a fresh connection. Auth errors are deliberately not retried (MCPAuthError also extends MCPConnectionError): token refresh already happens inside MCPClientWithAuthRetry, and re-invoking after a final auth failure could execute the tool twice. ValueError (transport-level parsing failures that corrupt the session) evicts the entry without a retry;
  • clients that fail to initialize are never pooled, and an atexit hook (mirroring core/helper/http_client_pooling.py) closes the pool on process exit;
  • behavior change: an unreachable or structurally broken server now fails fast on every call until its pool entry expires (default 300s), instead of paying a fresh connection attempt per call.

Tool listing is unchanged (still connect-per-call) — happy to follow up if reuse is wanted there too.

Fixes #42650

Tests

  • 14 unit tests in api/tests/unit_tests/core/mcp/test_client_manager.py: scope reuse, credential-change reuse, user/tenant/provider isolation, dead-connection eviction + retry, auth-error propagation without eviction, ValueError eviction without retry, a hung call not blocking other scopes, idle-eviction of an in-flight entry, a same-key registration race (neither caller raises, loser's connection closed, one entry pooled), TTL and LRU eviction, failed-init not pooled, close_all. The two concurrency cases were verified red against the pre-fix implementation during development.
  • One existing test seam migrated from patching MCPClientWithAuthRetry in tool.py to patching the manager.
  • pytest tests/unit_tests/core/mcp tests/unit_tests/core/tools tests/unit_tests/tools → 867 passed; ruff check / ruff format clean.

@yang0228

Copy link
Copy Markdown

Thanks — the pooling direction is right, and keying by connection fingerprint (URL + effective headers + timeouts) is a sensible first cut. Four things before this can merge, though: (1) the lock ordering can wedge the registry for every caller; (2) the key is built from per-invocation credentials, so it never matches when identity forwarding is on — the Playwright scenario in #42650 stays broken; (3) with forwarding off it matches too broadly — one session shared across users and concurrent runs; (4) nothing closes the pool in production. Details and a runnable repro below, traced against 9a0961a4a9 (current main) plus this PR.

1. Blocking: self._lock is held while _close_entry waits on entry.lock

_close_entry takes the per-entry lock (client_manager.py:90), and it is called from four places while self._lock is held:

  • _evict_expired (:101) ← called at :124 inside with self._lock:
  • _evict_to_capacity (:109) ← called at :160 inside with self._lock:
  • _acquire race loser (:156) ← inside with self._lock:
  • close_all (:215) — this one is fine, the lock is already released by then

Meanwhile invoke_tool holds entry.lock for the entire call (:194). So the moment a call is in flight, self._lockentry.lock is a deadlock waiting for a timeout, and the entry in flight is exactly the one _evict_expired picks.

The window opens as soon as a call has been in flight for longer than the TTL — 300 s with the default, or immediately if the TTL is tuned down (it's read from MCP_CLIENT_POOL_IDLE_TTL, so a deployment can set it to 0 or a small value). Calls that hang are the norm here, not the exception: ClientSession is constructed without read_timeout_seconds (mcp_client.py:103), so BaseSession.send_request's while True: keeps waiting on queue.Empty forever (base_session.py:235-241), and a server that accepts the request and never answers holds entry.lock indefinitely.

Result: once entered, that state never clears — one hung MCP call blocks MCP tool calls for every key (every tenant, every user). Before this PR a hung call only failed its own workflow.

Repro (my transcription of the locking structure in this PR, not your file — runnable as-is):

import threading, time
RELEASE, entered = threading.Event(), {"n": 0}

class Entry:
    def __init__(self, key):
        self.key, self.last_used, self.lock = key, time.monotonic(), threading.Lock()
        self.client = self
    def cleanup(self): pass
    def invoke_tool(self, *, tool_name, tool_args):
        entered["n"] += 1
        RELEASE.wait()          # server never replies
        return "ok"

class Manager:                  # mirrors PR :90 / :101 / :124 / :194
    def __init__(self, idle_ttl_seconds):
        self._clients, self._lock, self._idle_ttl_seconds = {}, threading.Lock(), idle_ttl_seconds
    def _close_entry(self, entry):
        with entry.lock:                       # PR :90
            entry.client.cleanup()
    def _evict_expired(self):
        now = time.monotonic()
        for key, entry in list(self._clients.items()):
            if now - entry.last_used >= self._idle_ttl_seconds:
                del self._clients[key]
                self._close_entry(entry)       # PR :101  <-- entry.lock under self._lock
    def _acquire(self, key):
        with self._lock:                       # PR :123
            self._evict_expired()              # PR :124  <-- the inversion
            entry = self._clients.get(key)
            if entry is not None:
                entry.last_used = time.monotonic()
                return entry
        entry = Entry(key)
        with self._lock:
            self._clients[key] = entry
            return entry
    def invoke_tool(self, key):
        entry = self._acquire(key)
        with entry.lock:                       # PR :194  <-- held for the whole call
            return entry.invoke_tool(tool_name="t", tool_args={})

manager = Manager(idle_ttl_seconds=0)
a = threading.Event()
threading.Thread(target=lambda: (a.set(), manager.invoke_tool("key-a")), daemon=True).start()
a.wait()
while entered["n"] != 1: time.sleep(0.01)

done_b, done_c = threading.Event(), threading.Event()
threading.Thread(target=lambda: (manager.invoke_tool("key-a"), done_b.set()), daemon=True).start()
time.sleep(0.2)
threading.Thread(target=lambda: (manager.invoke_tool("key-b"), done_c.set()), daemon=True).start()

print("unrelated key completed:", done_c.wait(timeout=3.0))   # False
print("same key completed:     ", done_b.wait(timeout=0.1))   # False
RELEASE.set()

Output: key-b — a different server, different tenant, no shared session — never completes until the hung call returns. Run it with a plain threading.Lock in place of _close_entry's and it returns immediately.

Minimal fix: never call _close_entry while holding the registry lock. Have the eviction helpers collect victims and remove them from _clients under the lock, then close them after releasing it:

def _evict_expired(self) -> list[_PooledClient]:
    now, victims = time.monotonic(), []
    for key, entry in list(self._clients.items()):
        if now - entry.last_used >= self._idle_ttl_seconds:
            del self._clients[key]
            victims.append(entry)
    return victims          # caller closes after `with self._lock:` exits

and callers become with self._lock: ... ; for v in victims: self._close_entry(v). Same for _evict_to_capacity (:160) and the race-loser branch (:156, close candidate after the with block). Worth a regression test: two clients on distinct keys, one call blocked on an event, assert the other completes within a timeout.

2. The pool key is derived from per-invocation credentials, so it defeats itself

invoke_remote_mcp_tool rebuilds headers per call. Two paths make those headers differ on every invocation, which means a new key — and therefore a fresh MCPClientWithAuthRetry and a fresh initialize():

  • Forwarded identity (core/tools/mcp_tool/tool.py:321-323_inject_forwarded_identity): X-Dify-SSO-Token is minted by EnterpriseService.issue_mcp_token on every call (services/enterprise/enterprise_service.py:123-159 — no cache, it's a POST to /mcp/issue-token). Different JWT every time ⇒ different _make_keyreuse never happens for any identity_mode=idp_token deployment. Those calls also pile a new entry into the pool each time, only for TTL/LRU to drop it.
  • OAuth refresh: MCPClientWithAuthRetry._handle_auth_error mutates self.headers["Authorization"] (core/mcp/auth_client.py:115). The comment at client_manager.py:43-45 correctly notes the stored key is never recomputed — but the next call passes the refreshed token into _make_key at :121, so it misses the pooled entry and connects again. The stored-key indirection doesn't save it.

3. The docstring's isolation claim doesn't hold; it's the reverse for stateful servers

_make_key's docstring says "Headers carry the (possibly per-user) credentials, so distinct tenants, users or tokens naturally map to distinct pooled connections." In the non-forwarding path, headers = provider_entity.decrypt_headers() (workspace-scoped) plus retrieve_tokens() on the workspace provider (core/tools/mcp_tool/tool.py:308-314) are identical for every user in a tenant. So the same key is produced for:

  • different end users of the same app, and
  • two concurrent workflow runs, and
  • two consecutive tool calls in one agent run that use the same tool name.

For a stateless MCP server that's fine. For the Playwright MCP server this PR targets, it means two users (or two overlapping runs) share one browser session — a cross-user session mix-up that connect-per-call never had.

4. Net effect on this issue

I traced the path for the scenario in the report — one AgentNode, the LLM calling browser_navigate and then browser_screenshot:

AgentNode._runbuild_parameters (core/workflow/nodes/agent/runtime_support.py:141) → the tool entity + runtime_parameters are serialized into the strategy parameters → the agent strategy plugin invokes the tool → PluginToolBackwardsInvocation.invoke_tool (core/plugin/backwards_invocation/tool.py:14-53) → ToolManager.get_tool_runtime_from_pluginget_tool_runtime's MCP branch (core/tools/tool_manager.py:391-397) → MCPToolProviderController.get_tool(...), which constructs a new MCPTool (core/tools/mcp_tool/provider.py:132).

So each LLM tool call carries its own MCPTool, and per §2 the key differs per call whenever identity forwarding is on — the Playwright browser still gets a new context. When forwarding is off, the key is equal, and the pool does reuse the session (that part works) — but per §3 that same key is shared across users and concurrent runs.

Two side effects worth confirming while you're here, both about runtime parameters, since tool configs (e.g. a Playwright --user-data-dir, or a browser_profile setting) may be delivered that way rather than through the host headers:

(a) Because the plugin path goes through get_tool_runtime_from_plugin, the node-configured runtime parameters prepared in build_parameters don't appear to reach the MCP call — what arrives in tool_parameters is what the LLM produced for the tool schema, and runtime_parameters is rebuilt from parameter.init_frontend_parameter() (tool_manager.py:527-532). I may be missing where the node's values are re-applied. If they genuinely don't flow, then for the report's setup the "browser" configuration isn't reaching the server at all, which would be worth a separate issue.

(b) _convert_tool_parameters_type resolves ToolProviderType.WORKFLOW parameters through the variable_pool argument (tool_manager.py:1166), and this path passes variable_pool=None — so workflow-tool parameters declared as variable selectors would resolve to empty here. Pre-existing, not caused by this PR, but it's on the same line you're already touching.

My read is that the pool key needs an execution-scope component (tenant + user + workflow run, or at least user) next to the connection fingerprint, so a run still reuses its own session while distinct runs/users stay separate. The forwarding case then also needs the stable identity in the key, not the minted token, otherwise §2 stands. Happy to put that up as a follow-up PR on latest main if you'd rather keep this one focused on the pooling mechanics.

5. Two things I'd hold this behind

6. Smaller points

  • Nothing closes the pool in production. close_all is called only from reset_mcp_client_manager (client_manager.py:237), which is documented as tests-only; there's no atexit/shutdown hook anywhere (compare api/core/helper/http_client_pooling.py:56). With a 300 s TTL that leaves Playwright's browser process alive for the whole window after a run ends, and each application instance holds its own pool — pre-PR the connection ended with the call. atexit.register(get_mcp_client_manager().close_all) would at least bound it, though you may want a per-run release instead.
  • Only MCPConnectionError evicts. A ValueError (e.g. the Unexpected content type injected at streamable_client.py:385) leaves the broken entry pooled for its full TTL, so every subsequent call on that key fails on the same dead client.
  • Permanent failures are now cached for the TTL. Pre-PR, a structurally broken server was retried from scratch each call; with pooling it fails fast for 300 s per fingerprint. That's arguably better, but it's a behavior change worth a line in the description.
  • DEFAULT_IDLE_TTL_SECONDS reads env at import time, so the knob needs a process restart; reading it per manager construction (or from dify_config) would be more consistent with the rest of the backend.
  • Nit: :192 and :201-203 exceed the line length that ruff format uses elsewhere in api/.

7. Tests

The 8 cases cover the pool mechanics well. Worth adding, all cheap: the concurrency case from §1 (blocked call must not block another key), _make_key inequality for two different user_ids on the same tenant (§3), and a case where the headers change between calls (§2) asserting reuse rather than a reconnect.

@LetMeSleep8h

Copy link
Copy Markdown
Author

Thanks for the thorough review — all four points are addressed in 155d75ee:

  1. Lock ordering: eviction helpers now only remove victims under the registry lock and close them after releasing it, exactly your minimal fix. One step further: _close_entry uses a non-blocking acquire and marks a busy entry doomed, so the in-flight call closes it in its finally — no thread ever waits on a lock held by a call that may never return. Added both regression tests you suggested (a blocked call on one scope while another completes, and same-key eviction of an in-flight entry with idle_ttl=0).

  2. Key redesign: the pool key is now the stable scope — tenant_id + user_id + provider_id + server_url + timeouts. Per-call credentials (minted forwarded tokens, refreshed OAuth tokens) are used to connect but never to key, so §2 and §3 fall out together: reuse works with identity forwarding on, and distinct users, tenants and provider configs get distinct sessions.

  3. Shutdown: atexit.register(close_all_mcp_clients), mirroring http_client_pooling.py.

  4. Smaller points: ValueError now evicts the entry without a retry (no re-invocation, since the tool may already have run); the env knobs are read at manager construction instead of import time; formatting nits fixed via ruff format.

On §5 — agreed both are pre-existing and pooling amplifies them. Beyond the ValueError eviction added here, a fuller guard (e.g. detecting a dead receive loop) probably belongs with fixes for #41499 / #41482 themselves; happy to help there. On §4(a)/(b) runtime-parameter plumbing — also pre-existing and out of this PR's scope, but worth separate issues if confirmed.

Tests: test_client_manager.py now has 13 cases (reuse, credential-change reuse, user/tenant/provider isolation, dead-connection retry, auth-error non-eviction, ValueError eviction, both concurrency cases, TTL and LRU eviction, failed-init, close_all). Full suite: pytest tests/unit_tests/core/mcp tests/unit_tests/core/tools/test_mcp_tool.py tests/unit_tests/tools/test_mcp_tool.py → 476 passed.

@yang0228

Copy link
Copy Markdown

Second pass on 155d75ee — all four points look addressed (the doomed-entry handoff in particular is a nicer answer than my minimal fix). One new problem though: the race-loser branch this commit adds crashes.

In _acquire, victims is only bound on the create path:

candidate = _PooledClient(client, key)
with self._lock:
    existing = self._clients.get(key)
    if existing is None:
        self._clients[key] = candidate
        victims = self._evict_to_capacity()     # bound only here
for victim in victims:                           # reached on both paths
    self._close_entry(victim)
if existing is not None:
    # Lost a race creating the same connection: use the winner.
    self._close_entry(candidate)
    return existing

When two callers race on the same key — exactly the case that loser branch exists for — the loser has existing is not None, so victims was never assigned and the for statement raises UnboundLocalError. The call then surfaces as ToolInvokeError("Failed to invoke tool: cannot access local variable 'victims' ...") instead of using the connection that was just opened. Both callers arriving before either registers is enough; no shared mutable state is needed.

The suite doesn't catch it because the concurrency tests use different scopes (test_blocked_call_does_not_block_other_scopes) or evict an entry that is already registered (test_evicting_in_flight_entry_does_not_wedge_new_acquires); nothing exercises two callers on one key.

Repro (my transcription of _acquire's structure, with both threads released together so they pass the first registry check before either registers):

import threading, time

CONNECT_WINDOW = threading.Event()

class Entry:
    def __init__(self, key):
        self.key, self.last_used = key, time.monotonic()
        self.lock, self.doomed, self.closed = threading.Lock(), False, False
        self.client = self
    def cleanup(self):
        self.closed = True

class Manager:                       # mirrors client_manager.py _acquire
    def __init__(self):
        self._clients, self._lock, self._max_size = {}, threading.Lock(), 100
    def _evict_to_capacity(self):
        return []                    # no overflow at max_size=100
    def _close_entry(self, entry):
        if not entry.lock.acquire(blocking=False):
            entry.doomed = True
            return
        try:
            if not entry.closed:
                entry.closed = True
                entry.client.cleanup()
        finally:
            entry.lock.release()
    def acquire(self, key):
        with self._lock:
            existing = self._clients.get(key)
            if existing is not None:
                existing.last_used = time.monotonic()
        if existing is not None:
            return existing
        CONNECT_WINDOW.wait()        # "connect" outside the registry lock
        candidate = Entry(key)
        with self._lock:
            existing = self._clients.get(key)
            if existing is None:
                self._clients[key] = candidate
                victims = self._evict_to_capacity()
        for victim in victims:       # <-- UnboundLocalError on the loser
            self._close_entry(victim)
        if existing is not None:
            self._close_entry(candidate)
            return existing
        return candidate

manager, errors, key = Manager(), [], "same-scope-key"

def caller(n):
    try:
        manager.acquire(key)
    except Exception as exc:
        errors.append((n, type(exc).__name__, str(exc)))

threads = [threading.Thread(target=caller, args=(n,), daemon=True) for n in (1, 2)]
for t in threads: t.start()
time.sleep(0.05)                     # both pass the registry check
CONNECT_WINDOW.set()                 # then both race the registration
for t in threads: t.join(timeout=5)

print("callers that raised:", errors)
print("pooled keys after the race:", list(manager._clients))

Output:

callers that raised: [(1, 'UnboundLocalError', "cannot access local variable 'victims' where it is not associated with a value")]
pooled keys after the race: ['same-scope-key']

Minimal fix — bind victims on both paths (this is the one-liner: victims: list[_PooledClient] = [] before the with), or fold the duplicate into the same list so there is a single close path:

duplicate: _PooledClient | None = None
with self._lock:
    existing = self._clients.get(key)
    if existing is None:
        self._clients[key] = candidate
        victims = self._evict_to_capacity()
    else:
        victims = []
        duplicate = candidate
for victim in victims:
    self._close_entry(victim)
if duplicate is not None:
    self._close_entry(duplicate)
    return existing
return candidate

Worth a test alongside the two you added: two threads on the same key, assert one client created and neither call raises. (The loser's client has already completed initialize(), so closing it there is also what keeps the server-side session from being orphaned.)

@LetMeSleep8h

Copy link
Copy Markdown
Author

On the race-loser crash — one correction with evidence, though the smell you flagged is real and already fixed:

In 155d75ee, victims is also bound unconditionally at the top of _acquire (victims = self._evict_expired(), client_manager.py:168), which your transcription omits. So the loser branch does not raise UnboundLocalError — it iterates the stale expired list, which was already closed a few lines earlier and is empty in the common case (and harmless otherwise, thanks to the idempotent closed guard). I verified this directly: the race test below passes against an unmodified checkout of 155d75ee.

That said, the shared name across two eviction sites was exactly the trap you identified, and a redundancy pass had already flagged it — a496b6c8 (pushed just now) splits it into expired / overflow, each bound before its registry block and closed exactly once by its own site. Same shape as your minimal fix, plus the untangling.

Added the test you sketched in ed09bba3: two callers on one key forced to construct concurrently via a threading.Barrier inside the mocked __enter__ (the same shape as your repro) — asserts neither caller raises, exactly one entry is pooled, and the loser's initialized connection is closed so its server-side session isn't orphaned.

Also refreshed the PR description to match the current design (stable-scope key, 14 tests, atexit hook, fail-fast-until-TTL behavior note).

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MCP tool loses browser context between calls — Playwright MCP navigate then snapshot returns about:blank

2 participants