Jeremiah Lowin commited on
Commit
75569fa
·
unverified ·
2 Parent(s): 64d1b4e8aad9e3

Merge pull request #309 from jlowin/proxy-mount

Browse files
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
- When you call `main_mcp.mount(prefix, server)`:
188
 
189
- 1. **Live Link**: A live connection is established between `main_mcp` and the `subserver`.
190
- 2. **Dynamic Updates**: Changes made to the `subserver` (e.g., adding new tools) **will be reflected** immediately when accessing components through `main_mcp`.
191
- 3. **Lifespan Management**: The `subserver`'s `lifespan` context **is automatically managed** and executed within the `main_mcp`'s lifespan.
192
- 4. **Delegation**: Requests for components matching the prefix are delegated to the subserver at runtime.
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
- Mount another FastMCP server on a given prefix.
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"]