Skip to content

fix: isolate connection-scoped transports per MCPConfig client - #5825

Closed
BlueX888 wants to merge 11 commits into
PrefectHQ:mainfrom
BlueX888:fix/mcpconfig-close-clone-teardown
Closed

BlueX888 wants to merge 11 commits into
PrefectHQ:mainfrom
BlueX888:fix/mcpconfig-close-clone-teardown

Conversation

@BlueX888

@BlueX888 BlueX888 commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Closes #5824

Description

Client.new() is a shallow copy, so a clone shared the original's MCPConfigTransport instance. That transport kept the backend transports of its most recent connection in a transport-wide _transports list which _build_composite() overwrote on every connect. Calling Client.close() on either client — even one that never connected — therefore closed whatever list was last written, tearing down the other client's live backends; its next call failed with ToolError: Error calling tool '...': Connection closed. This is the symmetric case of #5618/#5596: just as a clone connecting must not close the original's backends, an unconnected client closing must not either.

This change:

  • Adds MCPConfigTransport.copy_for_new_client() and uses it from Client.new(). The shallow copy preserves configuration, the concrete transport subclass, the eagerly-built single-server backend (including an auth: "oauth" provider and its token store), and _request_state_security — required so MRTR request-state tokens minted by one per-request client validate in the next client create_proxy(config) builds.
  • Makes teardown ownership-aware per backend: connect_session records participation only after a successful connect and decrements on context exit; close() releases a participated backend only when no other client's live connection uses it (shared active-user count vs this instance's local holds), while never-connected, failed, or cancelled clients release nothing. This covers connected-close-after-exit, reconnect-then-close, cancelled connect, and failed connect.
  • Keeps era resolution observable through the source transport: clones record _era_source and mirror _resolved_legacy_only up the copy chain, matching the pre-clone shared-object behavior that callers such as langchain's MCPAdapter rely on, without sharing teardown state.
  • Resets multi-server ownership per build (their exit stack already released the previous backends); client-level state stays independent.
  • Documents that single-server clones may share a keep-alive session/subprocess for OAuth continuity, while multi-server clones build separate backends.

Contribution type

  • Bug fix (simple, well-scoped fix for a clearly broken behavior)
  • Documentation improvement
  • Enhancement

Bug fix verification

Test paths: tests/test_mcp_config.py and tests/client/client/test_transport.py (regression selection: both close directions, connected-close ownership, single-server isolation, the four ownership cases — clone close after disconnect, stale clone close, cancelled clone connect, failed clone connect — OAuth/store preservation, subclass preservation, and request-state sharing).

Before the fix, with two real stdio servers (tests/client/minimal_stdio_server.py, offline):

[A] clone broken after unconnected original.close(): ToolError: Error calling tool 'pid': Connection closed
[B] original broken after unconnected clone.close(): ToolError: Error calling tool 'pid': Connection closed
12 failed, 1 passed, 74 deselected

The four ownership cases failed against earlier revisions of this fix with AssertionError: assert [<FastMCPTransport ...>] == []; the MRTR proxy suite failed on a rebuild-from-config revision with Invalid or expired requestState.

After the fix, both clients keep using the same backend PIDs and all 13 selected tests pass:

[A] clone ok: pid 70240 -> 70240
[B] original ok: pid 66548 -> 66548

Validation

  • uv run --frozen pytest -q tests/client/client/test_transport.py tests/test_mcp_config.py -> 76 passed, 11 skipped
  • uv run --frozen pytest -q tests/server/test_mrtr_guards.py -> 47 passed (includes TestMultiServerConfigEraMirroring)
  • uv run --frozen pytest -q tests/server/providers/proxy tests/client/client/test_client.py tests/client/client/test_session.py tests/client/client/test_mode_negotiation.py -> 314 passed
  • uv run --frozen pytest -q tests/client -> 671 passed, 9 skipped, 1 xfailed; 2 pre-existing unrelated Windows log-encoding failures (TestLogFile UnicodeDecodeError ... byte 0xa8), reproduced on clean adf90b6
  • uv run --isolated ty check -> All checks passed
  • uv run --frozen ruff check and ruff format --check on the changed files -> clean

AI assistance

Tool(s) used: Claude Code
How you used it: investigated from a verified reproduction, drafted the ownership model and regression tests, and revised it in response to review.

Checklist

  • This PR addresses an existing issue (or fixes a self-evident bug)
  • I have read CONTRIBUTING.md
  • I have added tests that cover my changes
  • I have run uv run prek run --all-files and all checks pass
  • I have self-reviewed my changes
  • If I used an LLM, it followed the repo's contributing conventions (not generic output)

@github-actions

This comment has been minimized.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-19T03:11:44.505948Z 20dc98f PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@marvin-context-protocol marvin-context-protocol Bot added bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. client Related to the FastMCP client SDK or client-side functionality. labels Oct 19, 2026

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 20dc98fccd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread fastmcp_slim/fastmcp/client/client.py Resolved
Comment thread fastmcp_slim/fastmcp/client/client.py Resolved
MCPConfigTransport kept the backend transports of its latest connection in a
transport-wide list. Client.new() is a shallow copy, so a clone shared the
transport: closing either client (even one that never connected) closed
whatever list `_build_composite()` last wrote, tearing down the other client's
live backends and making its next call fail with "Connection closed". This is
the symmetric case of PrefectHQ#5618.

Give each Client.new() clone an independent MCPConfigTransport over the same
config, so connection-scoped backend transports belong to one client. A
connected client's close still tears down its own backends, and the
connection's exit stack continues to own normal teardown.
Cloning a config client by rebuilding MCPConfigTransport from its raw config
recreated a single-server backend transport, and with it the OAuth provider and
its in-memory token store. Tokens acquired by the source client were lost, so a
fresh authorization flow could start on every proxy request that calls new().
It also flattened public MCPConfigTransport subclasses to the base class.

Copy the transport instead: sharing config and the eagerly-built backend keeps
OAuth and the concrete class, while fresh _transports, _resolved_legacy_only,
and _has_connected state keep the clone disconnected and close() scoped to the
connection the calling client opened. close() is now a no-op for a client that
never connected, which also protects the shared single-server backend.
A single boolean marking "this transport attempted a connect" is not enough:
single-server clones share the eagerly-built backend, so a clone that once
connected (or merely entered a connect that failed or was cancelled) could
still close the source client's live stdio session.

Track ownership per backend instead. Participation is recorded only after a
connect succeeds and is sticky so a client can still release its keep-alive
backend after the context exits. A shared live-connection counter shows when
another client is using a backend; each instance also tracks its own holds so
direct teardown (the cancelled-connect fallback) can release a backend when no
one else is using it. close() now requires participation and no other live
user. Multi-server connections reset ownership per build; their exit stack
already released the previous connection.

Also document the single-server sharing semantics in Client.new() and use
getattr for OAuth's bind-time token_storage_adapter in the test so the
repository-wide isolated ty check passes.

Tests cover the four delta-review cases: connected clone close after its
context exits, reconnect-then-close, cancelled connect, and failed connect.
create_proxy(config) builds a fresh client per request through Client.new(),
and a guard tool's multi-round-trip may span those clients. The sealing policy
lives on MCPConfigTransport precisely so later clones can validate tokens
minted by earlier ones; rebuilding the transport (an earlier revision) broke
tests/server/test_mrtr_guards.py with "Invalid or expired requestState".

copy_for_new_client() shallow-copies the transport and deliberately leaves
_request_state_security shared. Add a regression test pinning that identity so
a future ownership change cannot silently mint a new key.
langchain's MCPAdapter holds an MCPConfigTransport while its operations
connect through Client.new() clones, then reads transport.legacy_only after
the clone connected. Before clones got per-instance state both shared one
object, so the source observed the resolution. The shallow-copy revision kept
that state per instance and left the held transport reading False, failing the
langchain protocol-era integration test.

Record the copy source on each clone and mirror every era resolution (legacy,
modern, and the disconnect reset) up the copy chain. Clones keep their own
flag for their own reads; ownership and teardown state remain per-instance.

Add a regression test with a mixed-era multi-server config that connects via a
clone and asserts the source transport's legacy_only flips True while connected
and back to False on disconnect.
@BlueX888
BlueX888 force-pushed the fix/mcpconfig-close-clone-teardown branch from 733ce3e to 89e5010 Compare October 26, 2026 13:45
@BlueX888

Copy link
Copy Markdown
Contributor Author

Merged current main into the branch in 57c85d9; the fixes from cd6273a are retained. The two new regressions pass: test_remote_config_oauth_survives_new_clone and test_new_preserves_mcp_config_transport_subclass (2 passed).

@BlueX888

Copy link
Copy Markdown
Contributor Author

The Windows / Python 3.14 job failed in test_encrypted_oauth_reused_by_fresh_client[http-None] on a ResourceWarning (unclosed socket attributed to pydantic/json_schema.py), not an assertion about the client behavior. The same test passes locally on macOS/Python 3.12 (2 passed), and it predates this branch. It looks like a GC-timing flake in the warnings-catch assertion; a rerun of that job would confirm. I cannot rerun jobs without write access.

@BlueX888

Copy link
Copy Markdown
Contributor Author

Pushed f324332: the encrypted OAuth reuse test now keeps and closes the three clients it creates instead of letting them reach GC inside the warnings-catch block. That should remove the Windows/Python 3.14 ResourceWarning flake. tests/cli/test_client_commands.py: 130 passed.

@zzstoatzz

Copy link
Copy Markdown
Collaborator

Closing: Client.new() shares the transport by design, and a fix for #5824 should be a small guard in close(), not an ownership model.

@zzstoatzz zzstoatzz closed this Nov 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. bypass-issue-check client Related to the FastMCP client SDK or client-side functionality.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Client.close() on one MCPConfig client tears down a live Client.new() clone's backends

2 participants