Forward proxy initialize as bridge behavior (#4228)

This commit is contained in:
Jeremiah Lowin 2026-05-27 10:41:49 -04:00 committed by GitHub
commit 140d96aadd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 72 additions and 15 deletions

View file

@ -50,6 +50,12 @@ For hosts that use `mcpServers` JSON configuration, set the command to `uvx` and
}
```
## Endpoint URLs and Connection Status
Pass the full MCP endpoint URL for the remote server. Many FastMCP HTTP servers expose MCP at `/mcp`, so a local development server may need `http://localhost:8000/mcp` rather than `http://localhost:8000`.
`fastmcp-remote` starts a local stdio bridge, then connects to the upstream server when the MCP host initializes that bridge. If the upstream server is unavailable, the URL does not point to an MCP endpoint, or authentication cannot complete, initialization fails and the host should report the remote server as failed. After initialization succeeds, later tool, resource, prompt, and ping requests continue to proxy through the same remote server configuration.
OAuth is enabled automatically for HTTPS servers. The first connection opens the browser-based OAuth flow when the server requires authentication, then stores tokens locally for future runs.
To pass a bearer token or another custom header directly, provide `--header` in `Name: Value` form. The header name ends at the first colon, so values can contain additional colons. Quote the header when the value contains spaces, just like any other shell argument. An `Authorization` header disables OAuth by default:

View file

@ -49,6 +49,7 @@ if __name__ == "__main__":
```
This gives you:
- Safe concurrent request handling
- Automatic forwarding of MCP features (sampling, elicitation, etc.)
- Session isolation to prevent context mixing
@ -57,6 +58,14 @@ This gives you:
To mount a proxy inside another FastMCP server, see [Mounting External Servers](/servers/composition#mounting-external-servers).
</Tip>
## Connection Semantics
FastMCP proxies are lazy bridges. Creating the proxy object and starting the local server do not contact the upstream server. The upstream connection begins when an MCP client sends an `initialize` request to the proxy.
During initialization, the proxy initializes the upstream server before responding locally. If the upstream server is unavailable, the URL does not point to an MCP endpoint, or upstream authentication cannot complete, the proxy initialization fails. This keeps the local proxy's connection status aligned with the upstream server it represents.
After initialization, the proxy forwards MCP requests such as `ping`, `tools/list`, `resources/list`, `prompts/list`, tool calls, resource reads, sampling, elicitation, logging, and progress through the upstream client.
## Transport Bridging
A common use case is bridging transports between servers:

View file

@ -25,6 +25,10 @@ Run a remote MCP server through a local stdio bridge:
uvx fastmcp-remote https://example.com/mcp
```
Use the full MCP endpoint URL for the remote server. Many FastMCP HTTP servers expose MCP at `/mcp`, so a local development server may need `http://localhost:8000/mcp` rather than `http://localhost:8000`.
`fastmcp-remote` starts a local stdio bridge, then connects to the upstream server when the MCP host initializes that bridge. If the upstream server is unavailable, the URL does not point to an MCP endpoint, or authentication cannot complete, initialization fails and the host should report the remote server as failed.
For authenticated MCP servers, OAuth is enabled automatically. To pass a bearer token or other custom header instead, provide a header. The header name ends at the first colon, so values can contain additional colons. Quote the header when the value contains spaces, just like any other shell argument:
```bash

View file

@ -238,7 +238,6 @@ async def run(config: RemoteConfig) -> None:
client,
name="fastmcp-remote",
provider_error_strategy="raise",
validate_on_initialize=True,
)
if config.ignore_tools:
server.add_transform(IgnoreTools(config.ignore_tools))

View file

@ -31,9 +31,10 @@ from mcp.types import (
from pydantic.networks import AnyUrl
from fastmcp.client.client import Client, FastMCP1Server
from fastmcp.client.elicitation import ElicitResult
from fastmcp.client.logging import LogMessage
from fastmcp.client.roots import RootsList
from fastmcp.client.elicitation import ElicitResult, create_elicitation_callback
from fastmcp.client.logging import LogMessage, create_log_callback
from fastmcp.client.roots import RootsList, create_roots_callback
from fastmcp.client.sampling import create_sampling_callback
from fastmcp.client.telemetry import client_span
from fastmcp.client.transports import ClientTransportT
from fastmcp.exceptions import ResourceError
@ -88,8 +89,15 @@ class ProxyInitializeMiddleware(Middleware):
) -> mcp.types.InitializeResult | None:
client = await self.proxy._get_client()
try:
if isinstance(client, StatefulProxyClient):
ctx = context.fastmcp_context
if ctx is not None:
client._proxy_rc_ref[0] = (
ctx.request_context,
ctx._fastmcp,
)
async with client:
pass
await client.initialize()
except McpError:
raise
except (
@ -881,7 +889,6 @@ class FastMCPProxy(FastMCP):
*,
client_factory: ClientFactoryT,
provider_error_strategy: ProviderErrorStrategy = "warn",
validate_on_initialize: bool = False,
**kwargs,
):
"""Initialize the proxy server.
@ -896,8 +903,6 @@ class FastMCPProxy(FastMCP):
provider_error_strategy: How provider errors should affect aggregate
operations. Defaults to ``"warn"`` for compatibility; use
``"raise"`` when the proxy should surface upstream failures.
validate_on_initialize: If true, connect to the upstream server during
the incoming MCP initialize request.
**kwargs: Additional settings for the FastMCP server.
"""
super().__init__(**kwargs)
@ -905,8 +910,7 @@ class FastMCPProxy(FastMCP):
self.client_factory = client_factory
provider: Provider = ProxyProvider(client_factory)
self.add_provider(provider)
if validate_on_initialize:
self.middleware.append(ProxyInitializeMiddleware(self))
self.middleware.append(ProxyInitializeMiddleware(self))
self._setup_proxy_ping_handler()
async def _get_client(self) -> Client:
@ -1140,11 +1144,13 @@ class StatefulProxyClient(ProxyClient[ClientTransportT]):
# would resolve stale values in the receive loop. The restore helper
# constructs a fresh Context from the weakref after setting request_ctx.
_proxy_rc_ref: list[Any]
_proxy_restoring_handler_keys: set[str]
def __init__(self, *args: Any, **kwargs: Any):
# Install context-restoring handler wrappers BEFORE super().__init__
# registers them with the Client's session kwargs.
self._proxy_rc_ref = [None]
self._proxy_restoring_handler_keys = set()
for key, default_fn in (
("roots", default_proxy_roots_handler),
("sampling_handler", default_proxy_sampling_handler),
@ -1154,10 +1160,43 @@ class StatefulProxyClient(ProxyClient[ClientTransportT]):
):
if key not in kwargs:
kwargs[key] = _make_restoring_handler(default_fn, self._proxy_rc_ref)
self._proxy_restoring_handler_keys.add(key)
super().__init__(*args, **kwargs)
self._caches: dict[ServerSession, Client[ClientTransportT]] = {}
def _bind_restoring_handlers(self) -> None:
if "roots" in self._proxy_restoring_handler_keys:
self._session_kwargs["list_roots_callback"] = create_roots_callback(
_make_restoring_handler(default_proxy_roots_handler, self._proxy_rc_ref)
)
if "sampling_handler" in self._proxy_restoring_handler_keys:
self._session_kwargs["sampling_callback"] = create_sampling_callback(
_make_restoring_handler(
default_proxy_sampling_handler, self._proxy_rc_ref
)
)
if "elicitation_handler" in self._proxy_restoring_handler_keys:
self._session_kwargs["elicitation_callback"] = create_elicitation_callback(
_make_restoring_handler(
default_proxy_elicitation_handler, self._proxy_rc_ref
)
)
if "log_handler" in self._proxy_restoring_handler_keys:
self._session_kwargs["logging_callback"] = create_log_callback(
_make_restoring_handler(default_proxy_log_handler, self._proxy_rc_ref)
)
if "progress_handler" in self._proxy_restoring_handler_keys:
self._progress_handler = _make_restoring_handler(
default_proxy_progress_handler, self._proxy_rc_ref
)
def new(self) -> StatefulProxyClient[ClientTransportT]:
new_client = cast(StatefulProxyClient[ClientTransportT], super().new())
new_client._proxy_rc_ref = [None]
new_client._bind_restoring_handlers()
return new_client
async def __aexit__(self, exc_type, exc_value, traceback) -> None: # type: ignore[override] # ty:ignore[invalid-method-override]
"""The stateful proxy client will be forced disconnected when the session is exited.

View file

@ -259,16 +259,16 @@ async def test_proxy_ping_surfaces_wrong_remote_path():
async with run_server_async(remote, transport="http") as url:
proxy = create_proxy(StreamableHttpTransport(url.removesuffix("/mcp")))
async with Client(proxy) as client:
with pytest.raises(McpError, match="Session terminated"):
await client.ping()
with pytest.raises(McpError, match="Session terminated"):
async with Client(proxy):
pass
async def test_proxy_initialize_surfaces_remote_connection_error():
async def test_proxy_initialize_forwards_remote_connection_error():
port = find_available_port()
proxy = create_proxy(
StreamableHttpTransport(f"http://127.0.0.1:{port}/mcp"),
validate_on_initialize=True,
provider_error_strategy="raise",
)
with pytest.raises(McpError, match="Client failed to connect"):