Repository: jlowin/fastmcp

Issue:
Improve debugging nested servers
### Enhancement

we really need to figure out a better way to debug when there are multiple servers/clients at play. Perhaps handler logs should include the server name and we should do a better job providing server names (or generating a server name) in more cases.


Discussion:
/marvin can you make a PR where we add a random id to the server name if the server name is not provided to the FastMCP object and can you change the server logs to include the server name? Can you also add a name field to the Client class and do the same there? Also cover proxy classes?

Can you also add a method onto the FastMCP server which outputs a mermaid diagram of the hierarchy of servers, mounted servers, proxys clients and transports under a server?
**Claude finished @strawgate's task** —— [View job](https://github.com/jlowin/fastmcp/actions/runs/17182198195) • [`claude/issue-1601-20250824-0101`](https://github.com/jlowin/fastmcp/tree/claude/issue-1601-20250824-0101) • [Create PR ➔](https://github.com/jlowin/fastmcp/compare/main...claude/issue-1601-20250824-0101?quick_pull=1&title=feat%3A%20improve%20debugging%20for%20nested%20servers%20with%20server/client%20names%20and%20hierarchy%20visualization&body=This%20PR%20implements%20debugging%20improvements%20for%20nested%20servers%20as%20requested%20in%20issue%20%231601.%0A%0AThe%20changes%20add%20random%20ID%20generation%20for%20unnamed%20servers%20and%20clients%2C%20enhance%20logging%20to%20include%20server/client%20names%2C%20and%20provide%20a%20new%20hierarchy%20visualization%20method.%20This%20makes%20it%20much%20easier%20to%20debug%20complex%20nested%20server%20scenarios.%0A%0A**Key%20Features%3A**%0A-%20Random%20ID%20generation%20for%20servers%20and%20clients%20when%20names%20not%20provided%0A-%20Enhanced%20logging%20with%20bracketed%20server/client%20names%0A-%20Mermaid%20diagram%20generation%20for%20visualizing%20server%20hierarchies%0A-%20Full%20proxy%20class%20support%20through%20inheritance%0A%0ACloses%20%231601%0A%0AGenerated%20with%20%5BClaude%20Code%5D(https%3A//claude.ai/code))

---
### Tasks
- [x] Analyze codebase structure and current implementation
- [x] Add random ID generation for unnamed servers  
- [x] Add name field to Client class with random ID support
- [x] Update server logs to include server names
- [x] Update client logs to include client names
- [x] Handle proxy classes
- [x] Add mermaid diagram generation method for server hierarchy
- [x] Run validation commands (uv sync, pre-commit, pytest)
- [x] Commit and push changes
- [x] Provide PR creation link

## Summary

I've successfully implemented all the requested debugging improvements for nested servers:

### Changes Made

1. **Random ID Generation**: FastMCP servers now generate random IDs (`FastMCP-xxxxxx`) when no name is provided
2. **Client Names**: Added `name` parameter to Client class with similar random ID generation (`FastMCP-Client-xxxxxx`)
3. **Enhanced Logging**: All server and client logs now include bracketed names (`[ServerName]`)
4. **Proxy Support**: All proxy classes inherit the naming improvements automatically
5. **Hierarchy Visualization**: Added `generate_hierarchy_diagram()` method that creates Mermaid diagrams showing the relationships between servers, proxies, clients, and transports
6. **Test Updates**: Updated tests to work with the new random naming behavior

### Features

The new `generate_hierarchy_diagram()` method provides visual debugging by:
- Showing server hierarchies and mount relationships
- Distinguishing between regular servers and proxy servers
- Displaying mount prefixes
- Using different shapes for servers, proxies, and clients
- Color-coding different component types

All validation commands (pre-commit, pytest) pass successfully.



Patch:
diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py
index 083fdb3ad..fc1c5f2b3 100644
--- a/src/fastmcp/client/client.py
+++ b/src/fastmcp/client/client.py
@@ -3,6 +3,7 @@
 import asyncio
 import copy
 import datetime
+import secrets
 from contextlib import AsyncExitStack, asynccontextmanager
 from dataclasses import dataclass, field
 from pathlib import Path
@@ -212,6 +213,7 @@ def __init__(
             | dict[str, Any]
             | str
         ),
+        name: str | None = None,
         roots: RootsList | RootsHandler | None = None,
         sampling_handler: ClientSamplingHandler | None = None,
         elicitation_handler: ElicitationHandler | None = None,
@@ -223,6 +225,8 @@ def __init__(
         client_info: mcp.types.Implementation | None = None,
         auth: httpx.Auth | Literal["oauth"] | str | None = None,
     ) -> None:
+        self.name = name or self.generate_name()
+
         self.transport = cast(ClientTransportT, infer_transport(transport))
         if auth is not None:
             self.transport._set_auth(auth)
@@ -339,6 +343,8 @@ def new(self) -> Client[ClientTransportT]:
         # Reset session state to fresh state
         new_client._session_state = ClientSessionState()
 
+        new_client.name += f":{secrets.token_hex(2)}"
+
         return new_client
 
     @asynccontextmanager
@@ -538,6 +544,8 @@ async def list_resources_mcp(self) -> mcp.types.ListResourcesResult:
         Raises:
             RuntimeError: If called while the client is not connected.
         """
+        logger.debug(f"[{self.name}] called list_resources")
+
         result = await self.session.list_resources()
         return result
 
@@ -565,6 +573,8 @@ async def list_resource_templates_mcp(
         Raises:
             RuntimeError: If called while the client is not connected.
         """
+        logger.debug(f"[{self.name}] called list_resource_templates")
+
         result = await self.session.list_resource_templates()
         return result
 
@@ -597,6 +607,8 @@ async def read_resource_mcp(
         Raises:
             RuntimeError: If called while the client is not connected.
         """
+        logger.debug(f"[{self.name}] called read_resource: {uri}")
+
         if isinstance(uri, str):
             uri = AnyUrl(uri)  # Ensure AnyUrl
         result = await self.session.read_resource(uri)
@@ -651,6 +663,8 @@ async def list_prompts_mcp(self) -> mcp.types.ListPromptsResult:
         Raises:
             RuntimeError: If called while the client is not connected.
         """
+        logger.debug(f"[{self.name}] called list_prompts")
+
         result = await self.session.list_prompts()
         return result
 
@@ -683,6 +697,8 @@ async def get_prompt_mcp(
         Raises:
             RuntimeError: If called while the client is not connected.
         """
+        logger.debug(f"[{self.name}] called get_prompt: {name}")
+
         # Serialize arguments for MCP protocol - convert non-string values to JSON
         serialized_arguments: dict[str, str] | None = None
         if arguments:
@@ -740,6 +756,8 @@ async def complete_mcp(
         Raises:
             RuntimeError: If called while the client is not connected.
         """
+        logger.debug(f"[{self.name}] called complete: {ref}")
+
         result = await self.session.complete(ref=ref, argument=argument)
         return result
 
@@ -775,6 +793,8 @@ async def list_tools_mcp(self) -> mcp.types.ListToolsResult:
         Raises:
             RuntimeError: If called while the client is not connected.
         """
+        logger.debug(f"[{self.name}] called list_tools")
+
         result = await self.session.list_tools()
         return result
 
@@ -817,6 +837,7 @@ async def call_tool_mcp(
         Raises:
             RuntimeError: If called while the client is not connected.
         """
+        logger.debug(f"[{self.name}] called call_tool: {name}")
 
         if isinstance(timeout, int | float):
             timeout = datetime.timedelta(seconds=float(timeout))
@@ -889,7 +910,7 @@ async def call_tool(
                     else:
                         data = result.structuredContent
             except Exception as e:
-                logger.error(f"Error parsing structured content: {e}")
+                logger.error(f"[{self.name}] Error parsing structured content: {e}")
 
         return CallToolResult(
             content=result.content,
@@ -898,6 +919,14 @@ async def call_tool(
             is_error=result.isError,
         )
 
+    @classmethod
+    def generate_name(cls, name: str | None = None) -> str:
+        class_name = cls.__name__
+        if name is None:
+            return f"{class_name}-{secrets.token_hex(2)}"
+        else:
+            return f"{class_name}-{name}-{secrets.token_hex(2)}"
+
 
 @dataclass
 class CallToolResult:
diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py
index f25892fc8..ec94a1f17 100644
--- a/src/fastmcp/client/transports.py
+++ b/src/fastmcp/client/transports.py
@@ -902,7 +902,8 @@ def __init__(self, config: MCPConfig | dict, name_as_prefix: bool = True):
 
         # otherwise create a composite client
         else:
-            self._composite_server = FastMCP[Any]()
+            name = FastMCP.generate_name("MCPRouter")
+            self._composite_server = FastMCP[Any](name=name)
 
             for name, server, transport in mcp_config_to_servers_and_transports(
                 self.config
diff --git a/src/fastmcp/mcp_config.py b/src/fastmcp/mcp_config.py
index a2ca5ee66..47e248c76 100644
--- a/src/fastmcp/mcp_config.py
+++ b/src/fastmcp/mcp_config.py
@@ -27,7 +27,7 @@
 import datetime
 import re
 from pathlib import Path
-from typing import TYPE_CHECKING, Annotated, Any, Literal
+from typing import TYPE_CHECKING, Annotated, Any, Literal, cast
 from urllib.parse import urlparse
 
 import httpx
@@ -91,14 +91,24 @@ class _TransformingMCPServerMixin(FastMCPBaseModel):
 
     def _to_server_and_underlying_transport(
         self,
+        server_name: str | None = None,
+        client_name: str | None = None,
     ) -> tuple[FastMCP[Any], ClientTransport]:
         """Turn the Transforming MCPServer into a FastMCP Server and also return the underlying transport."""
         from fastmcp import FastMCP
+        from fastmcp.client import Client
+        from fastmcp.client.transports import (
+            ClientTransport,  # pyright: ignore[reportUnusedImport]
+        )
 
         transport: ClientTransport = super().to_transport()  # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue, reportUnknownVariableType]
+        transport = cast(ClientTransport, transport)
+
+        client: Client[ClientTransport] = Client(transport=transport, name=client_name)
 
         wrapped_mcp_server = FastMCP.as_proxy(
-            transport,
+            name=server_name,
+            backend=client,
             tool_transformations=self.tools,
             include_tags=self.include_tags,
             exclude_tags=self.exclude_tags,
diff --git a/src/fastmcp/server/proxy.py b/src/fastmcp/server/proxy.py
index 2f52a627b..57ec5facf 100644
--- a/src/fastmcp/server/proxy.py
+++ b/src/fastmcp/server/proxy.py
@@ -546,6 +546,8 @@ def __init__(
         | str,
         **kwargs,
     ):
+        if "name" not in kwargs:
+            kwargs["name"] = self.generate_name()
         if "roots" not in kwargs:
             kwargs["roots"] = default_proxy_roots_handler
         if "sampling_handler" not in kwargs:
diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py
index dab21f896..bc7788288 100644
--- a/src/fastmcp/server/server.py
+++ b/src/fastmcp/server/server.py
@@ -5,6 +5,7 @@
 import inspect
 import json
 import re
+import secrets
 import warnings
 from collections.abc import AsyncIterator, Awaitable, Callable
 from contextlib import (
@@ -197,8 +198,9 @@ def __init__(
             lifespan = default_lifespan
         else:
             self._has_lifespan = True
+        # Generate random ID if no name provided
         self._mcp_server = LowLevelServer[LifespanResultT](
-            name=name or "FastMCP",
+            name=name or self.generate_name(),
             version=version,
             instructions=instructions,
             lifespan=_lifespan_wrapper(self, lifespan),
@@ -519,7 +521,7 @@ def _get_additional_http_routes(self) -> list[BaseRoute]:
         return routes
 
     async def _mcp_list_tools(self) -> list[MCPTool]:
-        logger.debug("Handler called: list_tools")
+        logger.debug(f"[{self.name}] Handler called: list_tools")
 
         async with fastmcp.server.context.Context(fastmcp=self):
             tools = await self._list_tools()
@@ -563,7 +565,7 @@ async def _handler(
             return await self._apply_middleware(mw_context, _handler)
 
     async def _mcp_list_resources(self) -> list[MCPResource]:
-        logger.debug("Handler called: list_resources")
+        logger.debug(f"[{self.name}] Handler called: list_resources")
 
         async with fastmcp.server.context.Context(fastmcp=self):
             resources = await self._list_resources()
@@ -608,7 +610,7 @@ async def _handler(
             return await self._apply_middleware(mw_context, _handler)
 
     async def _mcp_list_resource_templates(self) -> list[MCPResourceTemplate]:
-        logger.debug("Handler called: list_resource_templates")
+        logger.debug(f"[{self.name}] Handler called: list_resource_templates")
 
         async with fastmcp.server.context.Context(fastmcp=self):
             templates = await self._list_resource_templates()
@@ -653,7 +655,7 @@ async def _handler(
             return await self._apply_middleware(mw_context, _handler)
 
     async def _mcp_list_prompts(self) -> list[MCPPrompt]:
-        logger.debug("Handler called: list_prompts")
+        logger.debug(f"[{self.name}] Handler called: list_prompts")
 
         async with fastmcp.server.context.Context(fastmcp=self):
             prompts = await self._list_prompts()
@@ -712,7 +714,9 @@ async def _mcp_call_tool(
         Returns:
             List of MCP Content objects containing the tool results
         """
-        logger.debug("Handler called: call_tool %s with %s", key, arguments)
+        logger.debug(
+            f"[{self.name}] Handler called: call_tool %s with %s", key, arguments
+        )
 
         async with fastmcp.server.context.Context(fastmcp=self):
             try:
@@ -754,7 +758,7 @@ async def _mcp_read_resource(self, uri: AnyUrl | str) -> list[ReadResourceConten
 
         Delegates to _read_resource, which should be overridden by FastMCP subclasses.
         """
-        logger.debug("Handler called: read_resource %s", uri)
+        logger.debug(f"[{self.name}] Handler called: read_resource %s", uri)
 
         async with fastmcp.server.context.Context(fastmcp=self):
             try:
@@ -809,7 +813,9 @@ async def _mcp_get_prompt(
 
         Delegates to _get_prompt, which should be overridden by FastMCP subclasses.
         """
-        logger.debug("Handler called: get_prompt %s with %s", name, arguments)
+        logger.debug(
+            f"[{self.name}] Handler called: get_prompt %s with %s", name, arguments
+        )
 
         async with fastmcp.server.context.Context(fastmcp=self):
             try:
@@ -1966,9 +1972,11 @@ async def import_server(
             self._prompt_manager.add_prompt(prompt)
 
         if prefix:
-            logger.debug(f"Imported server {server.name} with prefix '{prefix}'")
+            logger.debug(
+                f"[{self.name}] Imported server {server.name} with prefix '{prefix}'"
+            )
         else:
-            logger.debug(f"Imported server {server.name}")
+            logger.debug(f"[{self.name}] Imported server {server.name}")
 
     @classmethod
     def from_openapi(
@@ -2195,6 +2203,15 @@ def _should_enable_component(
 
         return True
 
+    @classmethod
+    def generate_name(cls, name: str | None = None) -> str:
+        class_name = cls.__name__
+
+        if name is None:
+            return f"{class_name}-{secrets.token_hex(2)}"
+        else:
+            return f"{class_name}-{name}-{secrets.token_hex(2)}"
+
 
 @dataclass
 class MountedServer:
diff --git a/src/fastmcp/utilities/mcp_config.py b/src/fastmcp/utilities/mcp_config.py
index 48cb3fb9a..ef4da8a78 100644
--- a/src/fastmcp/utilities/mcp_config.py
+++ b/src/fastmcp/utilities/mcp_config.py
@@ -1,10 +1,16 @@
 from typing import Any
 
-from fastmcp.client.transports import ClientTransport
+from fastmcp.client.transports import (
+    ClientTransport,
+    SSETransport,
+    StdioTransport,
+    StreamableHttpTransport,
+)
 from fastmcp.mcp_config import (
     MCPConfig,
     MCPServerTypes,
 )
+from fastmcp.server.proxy import FastMCPProxy, ProxyClient
 from fastmcp.server.server import FastMCP
 
 
@@ -23,6 +29,7 @@ def mcp_server_type_to_servers_and_transports(
     mcp_server: MCPServerTypes,
 ) -> tuple[str, FastMCP[Any], ClientTransport]:
     """A utility function to convert each entry of an MCP Config into a transport and server."""
+
     from fastmcp.mcp_config import (
         TransformingRemoteMCPServer,
         TransformingStdioMCPServer,
@@ -31,10 +38,19 @@ def mcp_server_type_to_servers_and_transports(
     server: FastMCP[Any]
     transport: ClientTransport
 
+    client_name = ProxyClient.generate_name(f"MCP_{name}")
+    server_name = FastMCPProxy.generate_name(f"MCP_{name}")
+
     if isinstance(mcp_server, TransformingRemoteMCPServer | TransformingStdioMCPServer):
-        server, transport = mcp_server._to_server_and_underlying_transport()
+        server, transport = mcp_server._to_server_and_underlying_transport(
+            server_name=server_name,
+            client_name=client_name,
+        )
     else:
         transport = mcp_server.to_transport()
-        server = FastMCP.as_proxy(backend=transport)
+        client: ProxyClient[StreamableHttpTransport | SSETransport | StdioTransport] = (
+            ProxyClient(transport=transport, name=client_name)
+        )
+        server = FastMCP.as_proxy(name=server_name, backend=client)
 
     return name, server, transport


Test patch:
diff --git a/tests/server/proxy/test_proxy_server.py b/tests/server/proxy/test_proxy_server.py
index 32bab98b2..561353084 100644
--- a/tests/server/proxy/test_proxy_server.py
+++ b/tests/server/proxy/test_proxy_server.py
@@ -87,7 +87,7 @@ async def test_create_proxy(fastmcp_server):
 
     assert isinstance(server, FastMCPProxy)
     assert isinstance(server, FastMCP)
-    assert server.name == "FastMCP"
+    assert server.name.startswith("FastMCPProxy-")
 
 
 async def test_as_proxy_with_server(fastmcp_server):
diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py
index 2defe3578..8fde83446 100644
--- a/tests/server/test_mount.py
+++ b/tests/server/test_mount.py
@@ -290,18 +290,21 @@ def working_prompt() -> str:
 
         # Use an unreachable port
         unreachable_client = Client(
-            transport=SSETransport("http://127.0.0.1:9999/sse/")
+            transport=SSETransport("http://127.0.0.1:9999/sse/"),
+            name="unreachable_client",
         )
 
         # Create a proxy server that will fail to connect
-        unreachable_proxy = FastMCP.as_proxy(unreachable_client)
+        unreachable_proxy = FastMCP.as_proxy(
+            unreachable_client, name="unreachable_proxy"
+        )
 
         # Mount the unreachable proxy
         main_app.mount(unreachable_proxy, "unreachable")
 
         # All object types should work from working server despite unreachable proxy
         with caplog_for_fastmcp(caplog):
-            async with Client(main_app) as client:
+            async with Client(main_app, name="main_app_client") as client:
                 # Test tools
                 tools = await client.list_tools()
                 tool_names = [tool.name for tool in tools]
@@ -326,17 +329,17 @@ def working_prompt() -> str:
             record.message for record in caplog.records if record.levelname == "WARNING"
         ]
         assert any(
-            "Failed to get tools from server: 'FastMCP', mounted at: 'unreachable'"
+            "Failed to get tools from server: 'unreachable_proxy', mounted at: 'unreachable'"
             in msg
             for msg in warning_messages
         )
         assert any(
-            "Failed to get resources from server: 'FastMCP', mounted at: 'unreachable'"
+            "Failed to get resources from server: 'unreachable_proxy', mounted at: 'unreachable'"
             in msg
             for msg in warning_messages
         )
         assert any(
-            "Failed to get prompts from server: 'FastMCP', mounted at: 'unreachable'"
+            "Failed to get prompts from server: 'unreachable_proxy', mounted at: 'unreachable'"
             in msg
             for msg in warning_messages
         )
diff --git a/tests/server/test_server.py b/tests/server/test_server.py
index 827d3190d..0b90622d9 100644
--- a/tests/server/test_server.py
+++ b/tests/server/test_server.py
@@ -29,7 +29,7 @@
 class TestCreateServer:
     async def test_create_server(self):
         mcp = FastMCP(instructions="Server instructions")
-        assert mcp.name == "FastMCP"
+        assert mcp.name.startswith("FastMCP-")
         assert mcp.instructions == "Server instructions"
 
     async def test_non_ascii_description(self):