Repository: jlowin/fastmcp

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_


Discussion:
That's a good point -- I think this is more a fault of documentation which I can update and an opportunity to make this easier.

Backstory: when an MCP is mounted, we attempt to access it as an in-memory Python object, which has a lot of latency and clarity benefits (e.g. if you're calling a mounted tool, we call the tool directly). However, it means that client-facing handshakes, like lifespans, don't get run because we are not accessing it via a Client object. Generally I think this is desirable for building modular applications that don't need the complexities of a client interaction in between them.

However, I totally get where you're coming from and why. You **can** get the behavior you want by treating the sub-MCP as a proxy server (even if you wrote it in the same file), in which case FastMCP will access it through an in-memory Client transport and invoke everything including its lifespans. 

Unfortunately it looks a bit ugly and is hard to figure this out. Here's how your snippet would look: *(I'm pretty sure this works, it hangs because your lifespan raises, but change it to a print/yield and it works on your first client call to the mounted server)*

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

So let's figure out how to make this both cleaner and better documented. Two thoughts come to mind for DX. The first is to add an `as_proxy()` method to every FastMCP:

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

However, I'm struggling to think of when it's useful to turn an existing FastMCP instance into a proxy except for this use case, in which case I think maybe the better starting point is a `as_proxy` kwarg on the mount method, like this:

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

How would that feel @strawgate? Would be an easy switch between "in memory" behavior (e.g. just load up the subserver as if I wrote them all as one server) and "proxy" behavior (e.g. treat this subserver as a standalone server and use a client to access it). And of course I'll write better documentation of all of this once we decide
Yeah I believe that the composition docs currently imply pretty heavily that that's the current behavior of mounting is to proxy hence my confusion

```
How Mounting Works
When you call main_mcp.mount(prefix, server):

Live Link: A live connection is established between main_mcp and the subserver.
Dynamic Updates: Changes made to the subserver (e.g., adding new tools) will be reflected immediately when accessing components through main_mcp.
Lifespan Management: The subserver's lifespan context is automatically managed and executed within the main_mcp's lifespan.
Delegation: Requests for components matching the prefix are delegated to the subserver at runtime.
```

Are there other situations other than lifespan management where internal proxy is useful? Perhaps proxy should be the default behavior if a lifespan is set on the mounted server? Or perhaps having a lifespan on a mounted server should be raise an error with instructions to use a proxy?
Yeah honestly I just didn't realize the implications of not using a client in `mount` until you brought this up, so the documentation is more a reflection of that... apologies! (Your read is absolutely correct, the documentation suggests a behavior that isn't true).

For the moment, I think as_proxy kwarg on `mount()` is a good starting point, and I like your suggestion about the default.


I'll have a PR in one sec



Patch:
diff --git a/docs/patterns/composition.mdx b/docs/patterns/composition.mdx
index 6d620954..bb1ac9c8 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 7c3c6da0..7bdc5b1b 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 59cb6f09..160d441c 100644
--- a/src/fastmcp/server/server.py
+++ b/src/fastmcp/server/server.py
@@ -218,7 +218,10 @@ def __init__(
         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 @@ def mount(
         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 dedb9e80..1b495cc3 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.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 @@ def welcome(name: str) -> str:
         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"]