Repository: jlowin/fastmcp (python)

Issue:
Mounted Server Lifespans
### Description

It looks like the generics on FastMCP require the mounted servers and the root server to share the same lifespan context type object?

It also looks like mounted server lifespans are never invoked
```
from contextlib import asynccontextmanager
from logging import getLogger
from fastmcp.server import FastMCP

@asynccontextmanager
async def mounted_lifespan(app):
    """Lifespan context manager for the MCP server."""
    raise "Running mounted lifespan."


mcp = FastMCP(name="Parent MCP Server")

mcp.mount(
    "/mounted",
    FastMCP(name="Mounted MCP Server", lifespan=mounted_lifespan),
)

if __name__ == "__main__":
    mcp.run(transport="sse")
```

and when using the tool only the context from a lifespan on the root server is available

### Example Code

```Python

```

### Version Information

```Text
2.2.6
```

### Additional Context

_No response_

Pull request description:
Support proxy mount
Closes #305 by supporting converting mounted servers to proxies via a new `as_proxy` flag. FastMCP servers that have lifespans are automatically converted to proxies unless the user specifies as_proxy=False.

Incidentally I've noticed in this that the low-level server seems to never close the lifespan properly? Post-yield hooks aren't called. i think this might be related to the issue discussed in #296 and needs investigation, though I think the problem is in how low-level server.run() is invoked.

Patch:
diff --git a/docs/patterns/composition.mdx b/docs/patterns/composition.mdx
index 6d62095..bb1ac9c 100644
--- a/docs/patterns/composition.mdx
+++ b/docs/patterns/composition.mdx
@@ -30,8 +30,6 @@ The choice of importing or mounting depends on your use case and requirements. I
 | **Method** | `FastMCP.import_server()` | `FastMCP.mount()` |
 | **Composition Type** | One-time copy (static) | Live link (dynamic) |
 | **Updates** | Changes to subserver NOT reflected | Changes to subserver immediately reflected |
-| **Lifespan** | Not managed | Automatically managed |
-| **Synchronicity** | Async (must be awaited) | Sync |
 | **Best For** | Bundling finalized components | Modular runtime composition |
 
 ### Proxy Servers
@@ -184,12 +182,12 @@ if __name__ == "__main__":
 
 ### How Mounting Works
 
-When you call `main_mcp.mount(prefix, server)`:
+Mounting creates a relationship between two servers where one server (the parent) delegates certain operations to another (the mounted server) based on prefixes. When mounting is configured:
 
-1. **Live Link**: A live connection is established between `main_mcp` and the `subserver`.
-2. **Dynamic Updates**: Changes made to the `subserver` (e.g., adding new tools) **will be reflected** immediately when accessing components through `main_mcp`.
-3. **Lifespan Management**: The `subserver`'s `lifespan` context **is automatically managed** and executed within the `main_mcp`'s lifespan.
-4. **Delegation**: Requests for components matching the prefix are delegated to the subserver at runtime.
+1. **Live Link**: The parent server establishes a connection to the mounted server.
+2. **Dynamic Updates**: Changes made to the mounted server (e.g., adding new tools) are immediately reflected when accessed through the parent server.
+3. **Prefixed Access**: The parent server uses prefixes to route requests to the mounted server.
+4. **Delegation**: Requests for components matching the prefix are delegated to the mounted server at runtime.
 
 The same prefixing rules apply as with `import_server` for naming tools, resources, templates, and prompts.
 
@@ -207,6 +205,49 @@ main_mcp.mount(
 )
 ```
 
+### Direct vs. Proxy Mounting
+
+FastMCP supports two modes for mounting servers:
+
+1. **Direct Mounting** (default): The parent server directly accesses the mounted server's objects in memory for optimal performance and observability. In this mode:
+   - No client lifecycle events occur on the mounted server
+   - The mounted server's lifespan context is not executed
+   - Communication is handled through direct method calls
+   
+2. **Proxy Mounting**: The parent server treats the mounted server as a separate entity and communicates with it through a client interface. In this mode:
+   - Full client lifecycle events occur on the mounted server
+   - The mounted server's lifespan is executed when a client connects
+   - Communication happens via an in-memory Client transport
+   - This preserves all client-facing behaviors but is slightly less efficient
+
+You can control which mode to use with the `as_proxy` parameter:
+
+```python
+# Direct mounting (default when no custom lifespan)
+main_mcp.mount("api", api_server)
+
+# Proxy mounting (preserves full client lifecycle)
+main_mcp.mount("api", api_server, as_proxy=True)
+```
+
+FastMCP automatically uses proxy mounting when the mounted server has a custom lifespan, but you can override this behavior by explicitly setting `as_proxy=False` or `as_proxy=True`.
+
+#### Interaction with Proxy Servers
+
+When using `FastMCP.from_client()` to create a proxy server, mounting that server will always use proxy mounting since the proxy server is already designed to be accessed via a client interface.
+
+```python
+from fastmcp import FastMCP, Client
+
+# Create a proxy for a remote server
+remote_proxy = FastMCP.from_client(Client("http://example.com/mcp"))
+
+# Mount the proxy - this will preserve full client lifecycle
+main_server.mount("remote", remote_proxy)
+```
+
+This is particularly useful for incorporating remote servers into your local application architecture.
+
 
 ## Example: Modular Application
 
diff --git a/docs/servers/fastmcp.mdx b/docs/servers/fastmcp.mdx
index 7c3c6da..7bdc5b1 100644
--- a/docs/servers/fastmcp.mdx
+++ b/docs/servers/fastmcp.mdx
@@ -245,6 +245,7 @@ sub = FastMCP(name="Sub")
 def hello(): 
     return "hi"
 
+# Mount directly
 main.mount("sub", sub)
 ```
 
diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py
index 59cb6f0..160d441 100644
--- a/src/fastmcp/server/server.py
+++ b/src/fastmcp/server/server.py
@@ -218,7 +218,10 @@ class FastMCP(Generic[LifespanResultT]):
         self._mounted_servers: dict[str, MountedServer] = {}
 
         if lifespan is None:
+            self._has_lifespan = False
             lifespan = default_lifespan
+        else:
+            self._has_lifespan = True
 
         self._mcp_server = MCPServer[LifespanResultT](
             name=name or "FastMCP",
@@ -944,10 +947,62 @@ class FastMCP(Generic[LifespanResultT]):
         tool_separator: str | None = None,
         resource_separator: str | None = None,
         prompt_separator: str | None = None,
+        as_proxy: bool | None = None,
     ) -> None:
+        """Mount another FastMCP server on this server with the given prefix.
+
+        Unlike importing (with import_server), mounting establishes a dynamic connection
+        between servers. When a client interacts with a mounted server's objects through
+        the parent server, requests are forwarded to the mounted server in real-time.
+        This means changes to the mounted server are immediately reflected when accessed
+        through the parent.
+
+        When a server is mounted:
+        - Tools from the mounted server are accessible with prefixed names using the tool_separator.
+          Example: If server has a tool named "get_weather", it will be available as "prefix_get_weather".
+        - Resources are accessible with prefixed URIs using the resource_separator.
+          Example: If server has a resource with URI "weather://forecast", it will be available as
+          "prefix+weather://forecast".
+        - Templates are accessible with prefixed URI templates using the resource_separator.
+          Example: If server has a template with URI "weather://location/{id}", it will be available
+          as "prefix+weather://location/{id}".
+        - Prompts are accessible with prefixed names using the prompt_separator.
+          Example: If server has a prompt named "weather_prompt", it will be available as
+          "prefix_weather_prompt".
+
+        There are two modes for mounting servers:
+        1. Direct mounting (default when server has no custom lifespan): The parent server
+           directly accesses the mounted server's objects in-memory for better performance.
+           In this mode, no client lifecycle events occur on the mounted server, including
+           lifespan execution.
+
+        2. Proxy mounting (default when server has a custom lifespan): The parent server
+           treats the mounted server as a separate entity and communicates with it via a
+           Client transport. This preserves all client-facing behaviors, including lifespan
+           execution, but with slightly higher overhead.
+
+        Args:
+            prefix: Prefix to use for the mounted server's objects.
+            server: The FastMCP server to mount.
+            tool_separator: Separator character for tool names (defaults to "_").
+            resource_separator: Separator character for resource URIs (defaults to "+").
+            prompt_separator: Separator character for prompt names (defaults to "_").
+            as_proxy: Whether to treat the mounted server as a proxy. If None (default),
+                automatically determined based on whether the server has a custom lifespan
+                (True if it has a custom lifespan, False otherwise).
         """
-        Mount another FastMCP server on a given prefix.
-        """
+        from fastmcp import Client
+        from fastmcp.client.transports import FastMCPTransport
+        from fastmcp.server.proxy import FastMCPProxy
+
+        # if as_proxy is not specified and the server has a custom lifespan,
+        # we should treat it as a proxy
+        if as_proxy is None:
+            as_proxy = server._has_lifespan
+
+        if as_proxy and not isinstance(server, FastMCPProxy):
+            server = FastMCPProxy(Client(transport=FastMCPTransport(server)))
+
         mounted_server = MountedServer(
             server=server,
             prefix=prefix,


Test patch:
diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py
index dedb9e8..1b495cc 100644
--- a/tests/server/test_mount.py
+++ b/tests/server/test_mount.py
@@ -1,4 +1,5 @@
 import json
+from contextlib import asynccontextmanager
 
 import pytest
 from mcp.server.lowlevel.helper_types import ReadResourceContents
@@ -8,6 +9,7 @@ from fastmcp import FastMCP
 from fastmcp.client import Client
 from fastmcp.client.transports import FastMCPTransport
 from fastmcp.exceptions import NotFoundError
+from fastmcp.server.proxy import FastMCPProxy
 
 
 class TestBasicMount:
@@ -427,3 +429,110 @@ class TestProxyServer:
         result = await main_app._mcp_get_prompt("proxy_welcome", {"name": "World"})
         assert result.messages is not None
         # The message should contain our welcome text
+
+
+class TestAsProxyKwarg:
+    """Test the as_proxy kwarg."""
+
+    async def test_as_proxy_defaults_false(self):
+        mcp = FastMCP("Main")
+        sub = FastMCP("Sub")
+
+        mcp.mount("sub", sub)
+
+        assert mcp._mounted_servers["sub"].server is sub
+
+    async def test_as_proxy_false(self):
+        mcp = FastMCP("Main")
+        sub = FastMCP("Sub")
+
+        mcp.mount("sub", sub, as_proxy=False)
+
+        assert mcp._mounted_servers["sub"].server is sub
+
+    async def test_as_proxy_true(self):
+        mcp = FastMCP("Main")
+        sub = FastMCP("Sub")
+
+        mcp.mount("sub", sub, as_proxy=True)
+
+        assert mcp._mounted_servers["sub"].server is not sub
+        assert isinstance(mcp._mounted_servers["sub"].server, FastMCPProxy)
+
+    async def test_as_proxy_defaults_true_if_lifespan(self):
+        @asynccontextmanager
+        async def lifespan(mcp: FastMCP):
+            yield
+
+        mcp = FastMCP("Main")
+        sub = FastMCP("Sub", lifespan=lifespan)
+
+        mcp.mount("sub", sub)
+
+        assert mcp._mounted_servers["sub"].server is not sub
+        assert isinstance(mcp._mounted_servers["sub"].server, FastMCPProxy)
+
+    async def test_as_proxy_ignored_for_proxy_mounts_default(self):
+        mcp = FastMCP("Main")
+        sub = FastMCP("Sub")
+        sub_proxy = FastMCP.from_client(Client(transport=FastMCPTransport(sub)))
+
+        mcp.mount("sub", sub_proxy)
+
+        assert mcp._mounted_servers["sub"].server is sub_proxy
+
+    async def test_as_proxy_ignored_for_proxy_mounts_false(self):
+        mcp = FastMCP("Main")
+        sub = FastMCP("Sub")
+        sub_proxy = FastMCP.from_client(Client(transport=FastMCPTransport(sub)))
+
+        mcp.mount("sub", sub_proxy, as_proxy=False)
+
+        assert mcp._mounted_servers["sub"].server is sub_proxy
+
+    async def test_as_proxy_ignored_for_proxy_mounts_true(self):
+        mcp = FastMCP("Main")
+        sub = FastMCP("Sub")
+        sub_proxy = FastMCP.from_client(Client(transport=FastMCPTransport(sub)))
+
+        mcp.mount("sub", sub_proxy, as_proxy=True)
+
+        assert mcp._mounted_servers["sub"].server is sub_proxy
+
+    async def test_as_proxy_mounts_still_have_live_link(self):
+        mcp = FastMCP("Main")
+        sub = FastMCP("Sub")
+
+        mcp.mount("sub", sub, as_proxy=True)
+
+        assert len(await mcp.get_tools()) == 0
+
+        @sub.tool()
+        def hello():
+            return "hi"
+
+        assert len(await mcp.get_tools()) == 1
+
+    async def test_sub_lifespan_is_executed(self):
+        lifespan_check = []
+
+        @asynccontextmanager
+        async def lifespan(mcp: FastMCP):
+            lifespan_check.append("start")
+            yield
+
+        mcp = FastMCP("Main")
+        sub = FastMCP("Sub", lifespan=lifespan)
+
+        @sub.tool()
+        def hello():
+            return "hi"
+
+        mcp.mount("sub", sub, as_proxy=True)
+
+        assert lifespan_check == []
+
+        async with Client(mcp) as client:
+            await client.call_tool("sub_hello", {})
+
+        assert lifespan_check == ["start"]