6.3 KiB
Proxy Mount Lifecycle Design
Goal
Reduce avoidable proxy churn during ProxyRuntime.reload() without pretending
that FastMCP's missing unmount lifecycle is solved.
The next step is not "fully correct hot reload." The next step is to introduce a small mount registry that can reuse unchanged enabled upstream proxy mounts across reloads, while keeping the current best-effort remount behavior for changed or removed connections.
Current State
ProxyRuntime.reload() currently:
- reloads config
- resets the server provider list to
local_provider - recreates the admin mount
- recreates a
Client,FastMCPProxy, andNamespace(connection.id)mount for every enabled connection
This is simple and currently works, but it churns every upstream proxy mount on every reload even when a connection is unchanged.
FastMCP does not currently expose a complete official mount/unmount lifecycle that we can rely on for safe per-connection teardown. The codebase should keep treating reload as best-effort until that exists.
Non-Goals
- Do not implement a custom general-purpose unmount system.
- Do not claim long-lived subscriptions survive reload.
- Do not wire proxy-result
ResourceLinkrewriting into runtime as part of this work. - Do not replace FastMCP's own proxy/provider internals.
- Do not change public MCP naming or config formats.
Recommended Design
Add a dedicated module:
src/wf_mcp/transparent_proxy/mounts.py
with two internal models:
ProxyMount
ProxyMountRegistry
ProxyMount
Owns the mount objects created for one enabled connection:
connection_id- a stable config fingerprint
- the FastMCP proxy object returned by
create_proxy(...) - any future lifecycle metadata we need
It should not initially promise ownership of client shutdown. FastMCP owns too much of that behavior today through its proxy/client factory internals.
ProxyMountRegistry
Owns reusable mounts keyed by connection_id.
Minimal responsibilities:
- return an existing mount when the enabled connection fingerprint is unchanged
- create a new mount when the connection is new or materially changed
- report which cached mounts are no longer active after a reload
- keep the stale/retired concept explicit so later cleanup can be added in one place when FastMCP exposes a safe lifecycle API
Initial API shape:
class ProxyMountRegistry:
def get_or_create(self, connection: ConnectionConfig, *, store_root: Path) -> ProxyMount:
...
def active_mounts_for(self, config: BrokerConfig) -> list[ProxyMount]:
...
def retired_connection_ids(self, active_connection_ids: set[str]) -> set[str]:
...
The exact API can shrink during implementation; the boundary is more important than these method names.
Reload Flow
Target flow:
reload config
validate config
reset mounted providers to local_provider
mount admin surface
ask ProxyMountRegistry for active enabled mounts
mount each active proxy
publish reload events
return ProxyReloadResult
For an unchanged enabled connection, the same proxy mount object should be reused across reloads.
For a changed connection, create a new mount using the new config fingerprint.
For a disabled or removed connection, stop mounting it and mark its cached mount retired. Do not invent unsafe teardown yet.
Fingerprint
Reuse should be based on the connection fields that affect upstream transport or identity:
idserveraccountenabledmetadata
The fingerprint must be deterministic and easy to test. It does not need to be a cryptographic API contract; it is an internal reuse key.
Why Not Just A Dict In reload()
A raw dictionary inside ProxyRuntime.reload() would work for the first happy
path, but it would bury lifecycle decisions in the loop:
- when a cached proxy is still valid
- when changed config invalidates it
- what "retired" means
- where future close/unmount logic belongs
That is exactly the logic likely to grow once FastMCP exposes a real lifecycle API. A small registry keeps the future deletion/replacement localized.
Relationship To FastMCP
FastMCP already gives us:
create_proxy(...)ProxyProvider- component-list caching
- forwarded advanced protocol behavior
We should keep using those pieces. This work should not subclass or monkeypatch FastMCP.
If FastMCP later ships official dynamic provider unmount or proxy lifecycle support, the registry should become thinner or disappear.
Testing
Add focused tests before implementation:
- unchanged enabled connection reuses the same proxy mount across reloads
- changed connection metadata produces a new mount
- disabled or removed connection is not remounted
- reload public payload remains unchanged
- existing list-changed event/notification behavior remains unchanged
Do not write tests that imply retired mounts are safely closed until that is actually implemented.
Deferred Work
- safe close/unmount of retired mounts
- preserving long-lived subscriptions across reload
- forwarding upstream notifications through mount lifecycle changes
- wiring
wf_mcp.proxy_resultshelpers into real tool-result handling - replacing FastMCP behavior that should be fixed upstream instead
Investigation Result
FastMCP 3.3.0 makes unchanged-mount reuse reasonable, with caveats:
create_proxy_mount()passes a disconnectedClientintocreate_proxy(...). FastMCP turns that intoclient.new()per request, so reusing aFastMCPProxydoes not keep one permanently connected upstream session alive.ProxyProviderintentionally caches tools, resources, templates, and prompts for lookup efficiency. The default TTL is 300 seconds.- Every explicit
list_*call refreshes the corresponding proxy cache. Direct lookup paths may use a still-fresh cached list until TTL expiry. FastMCP.mount()returns no provider handle, and no public general-purpose unmount API was found. Parent-side provider-list rebuild is still the only safe visible-surface removal we currently own.
Therefore the current registry behavior is acceptable:
- reuse unchanged enabled mounts
- rebuild changed mounts
- stop remounting disabled or removed mounts
- do not claim safe close/unmount of retired mounts yet
If dynamic upstream catalogs become a practical problem, prefer a documented refresh/invalidation policy over throwing away unchanged mounts on every reload.