Spaces:
Running
Running
Merge pull request #309 from jlowin/proxy-mount
Browse files- docs/patterns/composition.mdx +48 -7
- docs/servers/fastmcp.mdx +1 -0
- src/fastmcp/server/server.py +57 -2
- tests/server/test_mount.py +109 -0
docs/patterns/composition.mdx
CHANGED
|
@@ -30,8 +30,6 @@ The choice of importing or mounting depends on your use case and requirements. I
|
|
| 30 |
| **Method** | `FastMCP.import_server()` | `FastMCP.mount()` |
|
| 31 |
| **Composition Type** | One-time copy (static) | Live link (dynamic) |
|
| 32 |
| **Updates** | Changes to subserver NOT reflected | Changes to subserver immediately reflected |
|
| 33 |
-
| **Lifespan** | Not managed | Automatically managed |
|
| 34 |
-
| **Synchronicity** | Async (must be awaited) | Sync |
|
| 35 |
| **Best For** | Bundling finalized components | Modular runtime composition |
|
| 36 |
|
| 37 |
### Proxy Servers
|
|
@@ -184,12 +182,12 @@ if __name__ == "__main__":
|
|
| 184 |
|
| 185 |
### How Mounting Works
|
| 186 |
|
| 187 |
-
|
| 188 |
|
| 189 |
-
1. **Live Link**:
|
| 190 |
-
2. **Dynamic Updates**: Changes made to the
|
| 191 |
-
3. **
|
| 192 |
-
4. **Delegation**: Requests for components matching the prefix are delegated to the
|
| 193 |
|
| 194 |
The same prefixing rules apply as with `import_server` for naming tools, resources, templates, and prompts.
|
| 195 |
|
|
@@ -207,6 +205,49 @@ main_mcp.mount(
|
|
| 207 |
)
|
| 208 |
```
|
| 209 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
|
| 211 |
## Example: Modular Application
|
| 212 |
|
|
|
|
| 30 |
| **Method** | `FastMCP.import_server()` | `FastMCP.mount()` |
|
| 31 |
| **Composition Type** | One-time copy (static) | Live link (dynamic) |
|
| 32 |
| **Updates** | Changes to subserver NOT reflected | Changes to subserver immediately reflected |
|
|
|
|
|
|
|
| 33 |
| **Best For** | Bundling finalized components | Modular runtime composition |
|
| 34 |
|
| 35 |
### Proxy Servers
|
|
|
|
| 182 |
|
| 183 |
### How Mounting Works
|
| 184 |
|
| 185 |
+
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:
|
| 186 |
|
| 187 |
+
1. **Live Link**: The parent server establishes a connection to the mounted server.
|
| 188 |
+
2. **Dynamic Updates**: Changes made to the mounted server (e.g., adding new tools) are immediately reflected when accessed through the parent server.
|
| 189 |
+
3. **Prefixed Access**: The parent server uses prefixes to route requests to the mounted server.
|
| 190 |
+
4. **Delegation**: Requests for components matching the prefix are delegated to the mounted server at runtime.
|
| 191 |
|
| 192 |
The same prefixing rules apply as with `import_server` for naming tools, resources, templates, and prompts.
|
| 193 |
|
|
|
|
| 205 |
)
|
| 206 |
```
|
| 207 |
|
| 208 |
+
### Direct vs. Proxy Mounting
|
| 209 |
+
|
| 210 |
+
FastMCP supports two modes for mounting servers:
|
| 211 |
+
|
| 212 |
+
1. **Direct Mounting** (default): The parent server directly accesses the mounted server's objects in memory for optimal performance and observability. In this mode:
|
| 213 |
+
- No client lifecycle events occur on the mounted server
|
| 214 |
+
- The mounted server's lifespan context is not executed
|
| 215 |
+
- Communication is handled through direct method calls
|
| 216 |
+
|
| 217 |
+
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:
|
| 218 |
+
- Full client lifecycle events occur on the mounted server
|
| 219 |
+
- The mounted server's lifespan is executed when a client connects
|
| 220 |
+
- Communication happens via an in-memory Client transport
|
| 221 |
+
- This preserves all client-facing behaviors but is slightly less efficient
|
| 222 |
+
|
| 223 |
+
You can control which mode to use with the `as_proxy` parameter:
|
| 224 |
+
|
| 225 |
+
```python
|
| 226 |
+
# Direct mounting (default when no custom lifespan)
|
| 227 |
+
main_mcp.mount("api", api_server)
|
| 228 |
+
|
| 229 |
+
# Proxy mounting (preserves full client lifecycle)
|
| 230 |
+
main_mcp.mount("api", api_server, as_proxy=True)
|
| 231 |
+
```
|
| 232 |
+
|
| 233 |
+
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`.
|
| 234 |
+
|
| 235 |
+
#### Interaction with Proxy Servers
|
| 236 |
+
|
| 237 |
+
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.
|
| 238 |
+
|
| 239 |
+
```python
|
| 240 |
+
from fastmcp import FastMCP, Client
|
| 241 |
+
|
| 242 |
+
# Create a proxy for a remote server
|
| 243 |
+
remote_proxy = FastMCP.from_client(Client("http://example.com/mcp"))
|
| 244 |
+
|
| 245 |
+
# Mount the proxy - this will preserve full client lifecycle
|
| 246 |
+
main_server.mount("remote", remote_proxy)
|
| 247 |
+
```
|
| 248 |
+
|
| 249 |
+
This is particularly useful for incorporating remote servers into your local application architecture.
|
| 250 |
+
|
| 251 |
|
| 252 |
## Example: Modular Application
|
| 253 |
|
docs/servers/fastmcp.mdx
CHANGED
|
@@ -245,6 +245,7 @@ sub = FastMCP(name="Sub")
|
|
| 245 |
def hello():
|
| 246 |
return "hi"
|
| 247 |
|
|
|
|
| 248 |
main.mount("sub", sub)
|
| 249 |
```
|
| 250 |
|
|
|
|
| 245 |
def hello():
|
| 246 |
return "hi"
|
| 247 |
|
| 248 |
+
# Mount directly
|
| 249 |
main.mount("sub", sub)
|
| 250 |
```
|
| 251 |
|
src/fastmcp/server/server.py
CHANGED
|
@@ -219,7 +219,10 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 219 |
self._mounted_servers: dict[str, MountedServer] = {}
|
| 220 |
|
| 221 |
if lifespan is None:
|
|
|
|
| 222 |
lifespan = default_lifespan
|
|
|
|
|
|
|
| 223 |
|
| 224 |
self._mcp_server = MCPServer[LifespanResultT](
|
| 225 |
name=name or "FastMCP",
|
|
@@ -946,10 +949,62 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 946 |
tool_separator: str | None = None,
|
| 947 |
resource_separator: str | None = None,
|
| 948 |
prompt_separator: str | None = None,
|
|
|
|
| 949 |
) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 950 |
"""
|
| 951 |
-
|
| 952 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 953 |
mounted_server = MountedServer(
|
| 954 |
server=server,
|
| 955 |
prefix=prefix,
|
|
|
|
| 219 |
self._mounted_servers: dict[str, MountedServer] = {}
|
| 220 |
|
| 221 |
if lifespan is None:
|
| 222 |
+
self._has_lifespan = False
|
| 223 |
lifespan = default_lifespan
|
| 224 |
+
else:
|
| 225 |
+
self._has_lifespan = True
|
| 226 |
|
| 227 |
self._mcp_server = MCPServer[LifespanResultT](
|
| 228 |
name=name or "FastMCP",
|
|
|
|
| 949 |
tool_separator: str | None = None,
|
| 950 |
resource_separator: str | None = None,
|
| 951 |
prompt_separator: str | None = None,
|
| 952 |
+
as_proxy: bool | None = None,
|
| 953 |
) -> None:
|
| 954 |
+
"""Mount another FastMCP server on this server with the given prefix.
|
| 955 |
+
|
| 956 |
+
Unlike importing (with import_server), mounting establishes a dynamic connection
|
| 957 |
+
between servers. When a client interacts with a mounted server's objects through
|
| 958 |
+
the parent server, requests are forwarded to the mounted server in real-time.
|
| 959 |
+
This means changes to the mounted server are immediately reflected when accessed
|
| 960 |
+
through the parent.
|
| 961 |
+
|
| 962 |
+
When a server is mounted:
|
| 963 |
+
- Tools from the mounted server are accessible with prefixed names using the tool_separator.
|
| 964 |
+
Example: If server has a tool named "get_weather", it will be available as "prefix_get_weather".
|
| 965 |
+
- Resources are accessible with prefixed URIs using the resource_separator.
|
| 966 |
+
Example: If server has a resource with URI "weather://forecast", it will be available as
|
| 967 |
+
"prefix+weather://forecast".
|
| 968 |
+
- Templates are accessible with prefixed URI templates using the resource_separator.
|
| 969 |
+
Example: If server has a template with URI "weather://location/{id}", it will be available
|
| 970 |
+
as "prefix+weather://location/{id}".
|
| 971 |
+
- Prompts are accessible with prefixed names using the prompt_separator.
|
| 972 |
+
Example: If server has a prompt named "weather_prompt", it will be available as
|
| 973 |
+
"prefix_weather_prompt".
|
| 974 |
+
|
| 975 |
+
There are two modes for mounting servers:
|
| 976 |
+
1. Direct mounting (default when server has no custom lifespan): The parent server
|
| 977 |
+
directly accesses the mounted server's objects in-memory for better performance.
|
| 978 |
+
In this mode, no client lifecycle events occur on the mounted server, including
|
| 979 |
+
lifespan execution.
|
| 980 |
+
|
| 981 |
+
2. Proxy mounting (default when server has a custom lifespan): The parent server
|
| 982 |
+
treats the mounted server as a separate entity and communicates with it via a
|
| 983 |
+
Client transport. This preserves all client-facing behaviors, including lifespan
|
| 984 |
+
execution, but with slightly higher overhead.
|
| 985 |
+
|
| 986 |
+
Args:
|
| 987 |
+
prefix: Prefix to use for the mounted server's objects.
|
| 988 |
+
server: The FastMCP server to mount.
|
| 989 |
+
tool_separator: Separator character for tool names (defaults to "_").
|
| 990 |
+
resource_separator: Separator character for resource URIs (defaults to "+").
|
| 991 |
+
prompt_separator: Separator character for prompt names (defaults to "_").
|
| 992 |
+
as_proxy: Whether to treat the mounted server as a proxy. If None (default),
|
| 993 |
+
automatically determined based on whether the server has a custom lifespan
|
| 994 |
+
(True if it has a custom lifespan, False otherwise).
|
| 995 |
"""
|
| 996 |
+
from fastmcp import Client
|
| 997 |
+
from fastmcp.client.transports import FastMCPTransport
|
| 998 |
+
from fastmcp.server.proxy import FastMCPProxy
|
| 999 |
+
|
| 1000 |
+
# if as_proxy is not specified and the server has a custom lifespan,
|
| 1001 |
+
# we should treat it as a proxy
|
| 1002 |
+
if as_proxy is None:
|
| 1003 |
+
as_proxy = server._has_lifespan
|
| 1004 |
+
|
| 1005 |
+
if as_proxy and not isinstance(server, FastMCPProxy):
|
| 1006 |
+
server = FastMCPProxy(Client(transport=FastMCPTransport(server)))
|
| 1007 |
+
|
| 1008 |
mounted_server = MountedServer(
|
| 1009 |
server=server,
|
| 1010 |
prefix=prefix,
|
tests/server/test_mount.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import json
|
|
|
|
| 2 |
|
| 3 |
import pytest
|
| 4 |
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
|
@@ -8,6 +9,7 @@ from fastmcp import FastMCP
|
|
| 8 |
from fastmcp.client import Client
|
| 9 |
from fastmcp.client.transports import FastMCPTransport
|
| 10 |
from fastmcp.exceptions import NotFoundError
|
|
|
|
| 11 |
|
| 12 |
|
| 13 |
class TestBasicMount:
|
|
@@ -427,3 +429,110 @@ class TestProxyServer:
|
|
| 427 |
result = await main_app._mcp_get_prompt("proxy_welcome", {"name": "World"})
|
| 428 |
assert result.messages is not None
|
| 429 |
# The message should contain our welcome text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import json
|
| 2 |
+
from contextlib import asynccontextmanager
|
| 3 |
|
| 4 |
import pytest
|
| 5 |
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
|
|
|
| 9 |
from fastmcp.client import Client
|
| 10 |
from fastmcp.client.transports import FastMCPTransport
|
| 11 |
from fastmcp.exceptions import NotFoundError
|
| 12 |
+
from fastmcp.server.proxy import FastMCPProxy
|
| 13 |
|
| 14 |
|
| 15 |
class TestBasicMount:
|
|
|
|
| 429 |
result = await main_app._mcp_get_prompt("proxy_welcome", {"name": "World"})
|
| 430 |
assert result.messages is not None
|
| 431 |
# The message should contain our welcome text
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
class TestAsProxyKwarg:
|
| 435 |
+
"""Test the as_proxy kwarg."""
|
| 436 |
+
|
| 437 |
+
async def test_as_proxy_defaults_false(self):
|
| 438 |
+
mcp = FastMCP("Main")
|
| 439 |
+
sub = FastMCP("Sub")
|
| 440 |
+
|
| 441 |
+
mcp.mount("sub", sub)
|
| 442 |
+
|
| 443 |
+
assert mcp._mounted_servers["sub"].server is sub
|
| 444 |
+
|
| 445 |
+
async def test_as_proxy_false(self):
|
| 446 |
+
mcp = FastMCP("Main")
|
| 447 |
+
sub = FastMCP("Sub")
|
| 448 |
+
|
| 449 |
+
mcp.mount("sub", sub, as_proxy=False)
|
| 450 |
+
|
| 451 |
+
assert mcp._mounted_servers["sub"].server is sub
|
| 452 |
+
|
| 453 |
+
async def test_as_proxy_true(self):
|
| 454 |
+
mcp = FastMCP("Main")
|
| 455 |
+
sub = FastMCP("Sub")
|
| 456 |
+
|
| 457 |
+
mcp.mount("sub", sub, as_proxy=True)
|
| 458 |
+
|
| 459 |
+
assert mcp._mounted_servers["sub"].server is not sub
|
| 460 |
+
assert isinstance(mcp._mounted_servers["sub"].server, FastMCPProxy)
|
| 461 |
+
|
| 462 |
+
async def test_as_proxy_defaults_true_if_lifespan(self):
|
| 463 |
+
@asynccontextmanager
|
| 464 |
+
async def lifespan(mcp: FastMCP):
|
| 465 |
+
yield
|
| 466 |
+
|
| 467 |
+
mcp = FastMCP("Main")
|
| 468 |
+
sub = FastMCP("Sub", lifespan=lifespan)
|
| 469 |
+
|
| 470 |
+
mcp.mount("sub", sub)
|
| 471 |
+
|
| 472 |
+
assert mcp._mounted_servers["sub"].server is not sub
|
| 473 |
+
assert isinstance(mcp._mounted_servers["sub"].server, FastMCPProxy)
|
| 474 |
+
|
| 475 |
+
async def test_as_proxy_ignored_for_proxy_mounts_default(self):
|
| 476 |
+
mcp = FastMCP("Main")
|
| 477 |
+
sub = FastMCP("Sub")
|
| 478 |
+
sub_proxy = FastMCP.from_client(Client(transport=FastMCPTransport(sub)))
|
| 479 |
+
|
| 480 |
+
mcp.mount("sub", sub_proxy)
|
| 481 |
+
|
| 482 |
+
assert mcp._mounted_servers["sub"].server is sub_proxy
|
| 483 |
+
|
| 484 |
+
async def test_as_proxy_ignored_for_proxy_mounts_false(self):
|
| 485 |
+
mcp = FastMCP("Main")
|
| 486 |
+
sub = FastMCP("Sub")
|
| 487 |
+
sub_proxy = FastMCP.from_client(Client(transport=FastMCPTransport(sub)))
|
| 488 |
+
|
| 489 |
+
mcp.mount("sub", sub_proxy, as_proxy=False)
|
| 490 |
+
|
| 491 |
+
assert mcp._mounted_servers["sub"].server is sub_proxy
|
| 492 |
+
|
| 493 |
+
async def test_as_proxy_ignored_for_proxy_mounts_true(self):
|
| 494 |
+
mcp = FastMCP("Main")
|
| 495 |
+
sub = FastMCP("Sub")
|
| 496 |
+
sub_proxy = FastMCP.from_client(Client(transport=FastMCPTransport(sub)))
|
| 497 |
+
|
| 498 |
+
mcp.mount("sub", sub_proxy, as_proxy=True)
|
| 499 |
+
|
| 500 |
+
assert mcp._mounted_servers["sub"].server is sub_proxy
|
| 501 |
+
|
| 502 |
+
async def test_as_proxy_mounts_still_have_live_link(self):
|
| 503 |
+
mcp = FastMCP("Main")
|
| 504 |
+
sub = FastMCP("Sub")
|
| 505 |
+
|
| 506 |
+
mcp.mount("sub", sub, as_proxy=True)
|
| 507 |
+
|
| 508 |
+
assert len(await mcp.get_tools()) == 0
|
| 509 |
+
|
| 510 |
+
@sub.tool()
|
| 511 |
+
def hello():
|
| 512 |
+
return "hi"
|
| 513 |
+
|
| 514 |
+
assert len(await mcp.get_tools()) == 1
|
| 515 |
+
|
| 516 |
+
async def test_sub_lifespan_is_executed(self):
|
| 517 |
+
lifespan_check = []
|
| 518 |
+
|
| 519 |
+
@asynccontextmanager
|
| 520 |
+
async def lifespan(mcp: FastMCP):
|
| 521 |
+
lifespan_check.append("start")
|
| 522 |
+
yield
|
| 523 |
+
|
| 524 |
+
mcp = FastMCP("Main")
|
| 525 |
+
sub = FastMCP("Sub", lifespan=lifespan)
|
| 526 |
+
|
| 527 |
+
@sub.tool()
|
| 528 |
+
def hello():
|
| 529 |
+
return "hi"
|
| 530 |
+
|
| 531 |
+
mcp.mount("sub", sub, as_proxy=True)
|
| 532 |
+
|
| 533 |
+
assert lifespan_check == []
|
| 534 |
+
|
| 535 |
+
async with Client(mcp) as client:
|
| 536 |
+
await client.call_tool("sub_hello", {})
|
| 537 |
+
|
| 538 |
+
assert lifespan_check == ["start"]
|