Repository: jlowin/fastmcp
Pull request #105 (2025-04-08 17:29:40)

Add MCP proxy server
```python
from fastmcp import FastMCP

client = # any FastMCPClient connected to an existing server

proxy = FastMCP.as_proxy(client)
```



Patch:
diff --git a/pyproject.toml b/pyproject.toml
index 2b13c2d..20e56e0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -32,11 +32,15 @@ dev = [
     "copychat>=0.5.2",
     "ipython>=8.12.3",
     "pdbpp>=0.10.3",
+    "dirty-equals>=0.9.0",
 ]
 
 [tool.pytest.ini_options]
 asyncio_mode = "auto"
 asyncio_default_fixture_loop_scope = "session"
+filterwarnings = [
+    "ignore:Accessing the 'model_fields' attribute on the instance is deprecated:DeprecationWarning",
+]
 
 [tool.hatch.version]
 source = "vcs"
diff --git a/src/fastmcp/clients/base.py b/src/fastmcp/clients/base.py
index ba95b6b..b2a2a45 100644
--- a/src/fastmcp/clients/base.py
+++ b/src/fastmcp/clients/base.py
@@ -165,16 +165,22 @@ class BaseClient(abc.ABC):
         """Send a resources/listResourceTemplates request."""
         return await self.session.list_resource_templates()
 
-    async def read_resource(self, uri: AnyUrl) -> mcp.types.ReadResourceResult:
+    async def read_resource(self, uri: AnyUrl | str) -> mcp.types.ReadResourceResult:
         """Send a resources/read request."""
+        if isinstance(uri, str):
+            uri = AnyUrl(uri)
         return await self.session.read_resource(uri)
 
-    async def subscribe_resource(self, uri: AnyUrl) -> None:
+    async def subscribe_resource(self, uri: AnyUrl | str) -> None:
         """Send a resources/subscribe request."""
+        if isinstance(uri, str):
+            uri = AnyUrl(uri)
         await self.session.subscribe_resource(uri)
 
-    async def unsubscribe_resource(self, uri: AnyUrl) -> None:
+    async def unsubscribe_resource(self, uri: AnyUrl | str) -> None:
         """Send a resources/unsubscribe request."""
+        if isinstance(uri, str):
+            uri = AnyUrl(uri)
         await self.session.unsubscribe_resource(uri)
 
     async def list_prompts(self) -> mcp.types.ListPromptsResult:
diff --git a/src/fastmcp/prompts/prompt_manager.py b/src/fastmcp/prompts/prompt_manager.py
index bab2d66..99317f2 100644
--- a/src/fastmcp/prompts/prompt_manager.py
+++ b/src/fastmcp/prompts/prompt_manager.py
@@ -11,20 +11,23 @@ class PromptManager(BasePromptManager):
     Adds ability to import prompts from other managers with prefixed names.
     """
 
-    def import_prompts(self, manager: "PromptManager", prefix: str) -> None:
+    def import_prompts(
+        self, manager: "PromptManager", prefix: str | None = None
+    ) -> None:
         """
         Import all prompts from another PromptManager with prefixed names.
 
         Args:
             manager: Another PromptManager instance to import prompts from
             prefix: Prefix to add to prompt names. The resulting prompt name will
-                   be in the format "{prefix}/{original_name}"
-                   For example, with prefix "weather" and prompt "forecast_prompt",
+                   be in the format "{prefix}{original_name}" if prefix is provided,
+                   otherwise the original name is used.
+                   For example, with prefix "weather/" and prompt "forecast_prompt",
                    the imported prompt would be available as "weather/forecast_prompt"
         """
         for name, prompt in manager._prompts.items():
             # Create prefixed name - we keep the original name in the Prompt object
-            prefixed_name = f"{prefix}/{name}"
+            prefixed_name = f"{prefix}{name}" if prefix else name
 
             # Log the import
             logger.debug(f"Importing prompt with name {name} as {prefixed_name}")
diff --git a/src/fastmcp/resources/resource_manager.py b/src/fastmcp/resources/resource_manager.py
index 64ae4f8..6fd51b7 100644
--- a/src/fastmcp/resources/resource_manager.py
+++ b/src/fastmcp/resources/resource_manager.py
@@ -10,20 +10,25 @@ logger = logging.getLogger(__name__)
 class ResourceManager(BaseResourceManager):
     """ResourceManager that adds methods to import resources from other managers."""
 
-    def import_resources(self, manager: "ResourceManager", prefix: str) -> None:
+    def import_resources(
+        self, manager: "ResourceManager", prefix: str | None = None
+    ) -> None:
         """Import resources from another resource manager.
 
-        Resources are imported with a prefixed URI. For example, if a resource has
-        URI "data://users" and you import it with prefix "app", the imported resource
-        will have URI "app+data://users".
+        Resources are imported with a prefixed URI if a prefix is provided. For example,
+        if a resource has URI "data://users" and you import it with prefix "app+", the
+        imported resource will have URI "app+data://users". If no prefix is provided,
+        the original URI is used.
 
         Args:
             manager: The ResourceManager to import from
-            prefix: A prefix to apply to the resource URIs
+            prefix: A prefix to apply to the resource URIs, including the delimiter.
+                   For example, "app+" would result in URIs like "app+data://users".
+                   If None, the original URI is used.
         """
         for uri, resource in manager._resources.items():
             # Create prefixed URI and copy the resource with the new URI
-            prefixed_uri = f"{prefix}+{uri}"
+            prefixed_uri = f"{prefix}{uri}" if prefix else uri
 
             # Log the import
             logger.debug(f"Importing resource with URI {uri} as {prefixed_uri}")
@@ -31,20 +36,27 @@ class ResourceManager(BaseResourceManager):
             # Store directly in resources dictionary
             self._resources[prefixed_uri] = resource
 
-    def import_templates(self, manager: "ResourceManager", prefix: str) -> None:
+    def import_templates(
+        self, manager: "ResourceManager", prefix: str | None = None
+    ) -> None:
         """Import resource templates from another resource manager.
 
-        Templates are imported with a prefixed URI template. For example, if a template has
-        URI template "data://users/{id}" and you import it with prefix "app", the
-        imported template will have URI template "app+data://users/{id}".
+        Templates are imported with a prefixed URI template if a prefix is provided.
+        For example, if a template has URI template "data://users/{id}" and you import
+        it with prefix "app+", the imported template will have URI template
+        "app+data://users/{id}". If no prefix is provided, the original URI template is used.
 
         Args:
             manager: The ResourceManager to import templates from
-            prefix: A prefix to apply to the template URIs
+            prefix: A prefix to apply to the template URIs, including the delimiter.
+                   For example, "app+" would result in URI templates like "app+data://users/{id}".
+                   If None, the original URI template is used.
         """
         for uri_template, template in manager._templates.items():
             # Create prefixed URI template and copy the template with the new URI template
-            prefixed_uri_template = f"{prefix}+{uri_template}"
+            prefixed_uri_template = (
+                f"{prefix}{uri_template}" if prefix else uri_template
+            )
 
             # Log the import
             logger.debug(
diff --git a/src/fastmcp/server/proxy.py b/src/fastmcp/server/proxy.py
new file mode 100644
index 0000000..4dda5e1
--- /dev/null
+++ b/src/fastmcp/server/proxy.py
@@ -0,0 +1,212 @@
+from typing import Any, cast
+
+import mcp.types
+from mcp.server.fastmcp.prompts import Prompt
+from mcp.server.fastmcp.resources import Resource, ResourceTemplate
+from mcp.server.fastmcp.tools.base import Tool
+from mcp.server.fastmcp.utilities.func_metadata import func_metadata
+from mcp.types import BlobResourceContents, PromptMessage, TextResourceContents
+
+from fastmcp.clients.base import BaseClient
+from fastmcp.server.context import Context
+from fastmcp.server.server import FastMCP
+from fastmcp.utilities.logging import get_logger
+
+logger = get_logger(__name__)
+
+
+def _proxy_passthrough():
+    pass
+
+
+class ProxyTool(Tool):
+    def __init__(self, client: "BaseClient", **kwargs):
+        super().__init__(**kwargs)
+        self._client = client
+
+    @classmethod
+    async def from_client(
+        cls, client: "BaseClient", tool: mcp.types.Tool
+    ) -> "ProxyTool":
+        return cls(
+            client=client,
+            name=tool.name,
+            description=tool.description,
+            parameters=tool.inputSchema,
+            fn=_proxy_passthrough,
+            fn_metadata=func_metadata(_proxy_passthrough),
+            is_async=True,
+        )
+
+    async def run(
+        self, arguments: dict[str, Any], context: Context | None = None
+    ) -> Any:
+        async with self._client:
+            result = await self._client.call_tool(self.name, arguments)
+        if result.isError:
+            raise ValueError(cast(mcp.types.TextContent, result.content[0]).text)
+        return result.content[0]
+
+
+class ProxyResource(Resource):
+    def __init__(
+        self, client: "BaseClient", *, _value: str | bytes | None = None, **kwargs
+    ):
+        super().__init__(**kwargs)
+        self._client = client
+        self._value = _value
+
+    @classmethod
+    async def from_client(
+        cls, client: "BaseClient", resource: mcp.types.Resource
+    ) -> "ProxyResource":
+        return cls(
+            client=client,
+            uri=resource.uri,
+            name=resource.name,
+            description=resource.description,
+            mime_type=resource.mimeType,
+        )
+
+    async def read(self) -> str | bytes:
+        if self._value is not None:
+            return self._value
+
+        async with self._client:
+            result = await self._client.read_resource(self.uri)
+        if isinstance(result.contents[0], TextResourceContents):
+            return result.contents[0].text
+        elif isinstance(result.contents[0], BlobResourceContents):
+            return result.contents[0].blob
+        else:
+            raise ValueError(f"Unsupported content type: {type(result.contents[0])}")
+
+
+class ProxyTemplate(ResourceTemplate):
+    def __init__(self, client: "BaseClient", **kwargs):
+        super().__init__(**kwargs)
+        self._client = client
+
+    @classmethod
+    async def from_client(
+        cls, client: "BaseClient", template: mcp.types.ResourceTemplate
+    ) -> "ProxyTemplate":
+        return cls(
+            client=client,
+            uri_template=template.uriTemplate,
+            name=template.name,
+            description=template.description,
+            fn=_proxy_passthrough,
+            parameters={},
+        )
+
+    async def create_resource(self, uri: str, params: dict[str, Any]) -> ProxyResource:
+        async with self._client:
+            result = await self._client.read_resource(uri)
+
+        if isinstance(result.contents[0], TextResourceContents):
+            value = result.contents[0].text
+        elif isinstance(result.contents[0], BlobResourceContents):
+            value = result.contents[0].blob
+        else:
+            raise ValueError(f"Unsupported content type: {type(result.contents[0])}")
+
+        return ProxyResource(
+            client=self._client,
+            uri=uri,
+            name=self.name,
+            description=self.description,
+            mime_type=result.contents[0].mimeType,
+            contents=result.contents,
+            _value=value,
+        )
+
+
+class ProxyPrompt(Prompt):
+    def __init__(self, client: "BaseClient", **kwargs):
+        super().__init__(**kwargs)
+        self._client = client
+
+    @classmethod
+    async def from_client(
+        cls, client: "BaseClient", prompt: mcp.types.Prompt
+    ) -> "ProxyPrompt":
+        return cls(
+            client=client,
+            name=prompt.name,
+            description=prompt.description,
+            arguments=[a.model_dump() for a in prompt.arguments or []],
+            fn=_proxy_passthrough,
+        )
+
+    async def render(self, arguments: dict[str, Any]) -> list[PromptMessage]:
+        async with self._client:
+            result = await self._client.get_prompt(self.name, arguments)
+        return result.messages
+
+
+class FastMCPProxy(FastMCP):
+    def __init__(self, _async_constructor: bool, **kwargs):
+        if not _async_constructor:
+            raise ValueError(
+                "FastMCPProxy() was initialied unexpectedly. Please use a constructor like `FastMCPProxy.from_client()` instead."
+            )
+        super().__init__(**kwargs)
+
+    @classmethod
+    async def from_client(
+        cls, client: "BaseClient", name: str | None = None, **settings: Any
+    ) -> "FastMCPProxy":
+        """Create a FastMCP proxy server from a client.
+
+        This method creates a new FastMCP server instance that proxies requests to the provided client.
+        It discovers the client's tools, resources, prompts, and templates, and creates corresponding
+        components in the server that forward requests to the client.
+
+        Args:
+            client: The client to proxy requests to
+            name: Optional name for the new FastMCP server (defaults to client name if available)
+            **settings: Additional settings for the FastMCP server
+
+        Returns:
+            A FastMCP server that proxies requests to the client
+        """
+        server = cls(name=name, **settings, _async_constructor=True)
+
+        async with client:
+            # Register proxies for client tools
+            tools_result = await client.list_tools()
+            for tool in tools_result.tools:
+                tool_proxy = await ProxyTool.from_client(client, tool)
+                server._tool_manager._tools[tool_proxy.name] = tool_proxy
+                logger.debug(f"Created proxy for tool: {tool_proxy.name}")
+
+            # Register proxies for client resources
+            resources_result = await client.list_resources()
+            for resource in resources_result.resources:
+                resource_proxy = await ProxyResource.from_client(client, resource)
+                server._resource_manager._resources[str(resource_proxy.uri)] = (
+                    resource_proxy
+                )
+                logger.debug(f"Created proxy for resource: {resource_proxy.uri}")
+
+            # Register proxies for client resource templates
+            templates_result = await client.list_resource_templates()
+            for template in templates_result.resourceTemplates:
+                template_proxy = await ProxyTemplate.from_client(client, template)
+                server._resource_manager._templates[template_proxy.uri_template] = (
+                    template_proxy
+                )
+                logger.debug(
+                    f"Created proxy for template: {template_proxy.uri_template}"
+                )
+
+            # Register proxies for client prompts
+            prompts_result = await client.list_prompts()
+            for prompt in prompts_result.prompts:
+                prompt_proxy = await ProxyPrompt.from_client(client, prompt)
+                server._prompt_manager._prompts[prompt_proxy.name] = prompt_proxy
+                logger.debug(f"Created proxy for prompt: {prompt_proxy.name}")
+
+            logger.info(f"Created server '{server.name}' proxying to client: {client}")
+            return server
diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py
index d114e61..7d63f6d 100644
--- a/src/fastmcp/server/server.py
+++ b/src/fastmcp/server/server.py
@@ -1,4 +1,4 @@
-from typing import Any, Dict
+from typing import TYPE_CHECKING, Any, Dict
 
 import mcp.server.fastmcp
 import mcp.types
@@ -9,6 +9,11 @@ from fastmcp.server.context import Context
 from fastmcp.tools.tool_manager import ToolManager
 from fastmcp.utilities.logging import get_logger
 
+if TYPE_CHECKING:
+    from fastmcp.clients.base import BaseClient
+
+    from .proxy import FastMCPProxy
+
 logger = get_logger(__name__)
 
 
@@ -62,20 +67,41 @@ class FastMCP(mcp.server.fastmcp.FastMCP):
         # Mount the app in the list of mounted apps
         self._mounted_apps[prefix] = app
 
-        # Import tools from the mounted app
-        self._tool_manager.import_tools(app._tool_manager, prefix)
-
-        # Import resources from the mounted app
-        self._resource_manager.import_resources(app._resource_manager, prefix)
+        # Import tools from the mounted app with / delimiter
+        tool_prefix = f"{prefix}/"
+        self._tool_manager.import_tools(app._tool_manager, tool_prefix)
 
-        # Import resource templates
-        self._resource_manager.import_templates(app._resource_manager, prefix)
+        # Import resources and templates from the mounted app with + delimiter
+        resource_prefix = f"{prefix}+"
+        self._resource_manager.import_resources(app._resource_manager, resource_prefix)
+        self._resource_manager.import_templates(app._resource_manager, resource_prefix)
 
-        # Import prompts
-        self._prompt_manager.import_prompts(app._prompt_manager, prefix)
+        # Import prompts with / delimiter
+        prompt_prefix = f"{prefix}/"
+        self._prompt_manager.import_prompts(app._prompt_manager, prompt_prefix)
 
         logger.info(f"Mounted app with prefix '{prefix}'")
-        logger.debug(f"Imported tools with prefix '{prefix}/'")
-        logger.debug(f"Imported resources with prefix '{prefix}+'")
-        logger.debug(f"Imported templates with prefix '{prefix}+'")
-        logger.debug(f"Imported prompts with prefix '{prefix}/'")
+        logger.debug(f"Imported tools with prefix '{tool_prefix}'")
+        logger.debug(f"Imported resources with prefix '{resource_prefix}'")
+        logger.debug(f"Imported templates with prefix '{resource_prefix}'")
+        logger.debug(f"Imported prompts with prefix '{prompt_prefix}'")
+
+    @classmethod
+    async def as_proxy(cls, client: "BaseClient", **settings: Any) -> "FastMCPProxy":
+        """
+        Create a FastMCP proxy server from a client.
+
+        This method creates a new FastMCP server instance that proxies requests to the provided client.
+        It discovers the client's tools, resources, prompts, and templates, and creates corresponding
+        components in the server that forward requests to the client.
+
+        Args:
+            client: The client to proxy requests to
+            **settings: Additional settings for the FastMCP server
+
+        Returns:
+            A FastMCP server that proxies requests to the client
+        """
+        from .proxy import FastMCPProxy
+
+        return await FastMCPProxy.from_client(client=client, **settings)
diff --git a/src/fastmcp/tools/tool_manager.py b/src/fastmcp/tools/tool_manager.py
index 133c93c..1b3b955 100644
--- a/src/fastmcp/tools/tool_manager.py
+++ b/src/fastmcp/tools/tool_manager.py
@@ -12,19 +12,22 @@ class ToolManager(mcp.server.fastmcp.tools.ToolManager):
     Adds ability to import tools from other managers with prefixed names.
     """
 
-    def import_tools(self, tool_manager: "ToolManager", prefix: str) -> None:
+    def import_tools(
+        self, tool_manager: "ToolManager", prefix: str | None = None
+    ) -> None:
         """
         Import all tools from another ToolManager with prefixed names.
 
         Args:
             tool_manager: Another ToolManager instance to import tools from
-            prefix: Prefix to add to tool names. The resulting tool name will
-                   be in the format "{prefix}/{original_name}"
-                   For example, with prefix "weather" and tool "forecast",
+            prefix: Prefix to add to tool names, including the delimiter.
+                   The resulting tool name will be in the format "{prefix}{original_name}"
+                   if prefix is provided, otherwise the original name is used.
+                   For example, with prefix "weather/" and tool "forecast",
                    the imported tool would be available as "weather/forecast"
         """
         for name, tool in tool_manager._tools.items():
-            prefixed_name = f"{prefix}/{name}"
+            prefixed_name = f"{prefix}{name}" if prefix else name
 
             # Create a shallow copy of the tool with the prefixed name
             copied_tool = Tool.from_function(
diff --git a/uv.lock b/uv.lock
index 3ac545e..9444459 100644
--- a/uv.lock
+++ b/uv.lock
@@ -169,6 +169,15 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190 },
 ]
 
+[[package]]
+name = "dirty-equals"
+version = "0.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b0/99/133892f401ced5a27e641a473c547d5fbdb39af8f85dac8a9d633ea3e7a7/dirty_equals-0.9.0.tar.gz", hash = "sha256:17f515970b04ed7900b733c95fd8091f4f85e52f1fb5f268757f25c858eb1f7b", size = 50412 }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/77/0c/03cc99bf3b6328604b10829de3460f2b2ad3373200c45665c38508e550c6/dirty_equals-0.9.0-py3-none-any.whl", hash = "sha256:ff4d027f5cfa1b69573af00f7ba9043ea652dbdce3fe5cbe828e478c7346db9c", size = 28226 },
+]
+
 [[package]]
 name = "distlib"
 version = "0.3.9"
@@ -220,7 +229,7 @@ wheels = [
 
 [[package]]
 name = "fastmcp"
-version = "0.4.2.dev21+g0b6e56c.d20250407"
+version = "0.4.2.dev28+g728aeec.d20250408"
 source = { editable = "." }
 dependencies = [
     { name = "mcp" },
@@ -232,6 +241,7 @@ dependencies = [
 [package.dev-dependencies]
 dev = [
     { name = "copychat" },
+    { name = "dirty-equals" },
     { name = "ipython" },
     { name = "pdbpp" },
     { name = "pre-commit" },
@@ -254,6 +264,7 @@ requires-dist = [
 [package.metadata.requires-dev]
 dev = [
     { name = "copychat", specifier = ">=0.5.2" },
+    { name = "dirty-equals", specifier = ">=0.9.0" },
     { name = "ipython", specifier = ">=8.12.3" },
     { name = "pdbpp", specifier = ">=0.10.3" },
     { name = "pre-commit" },


Test patch:
diff --git a/tests/clients/test_fastmcp_client.py b/tests/clients/test_fastmcp_client.py
index 0d4b881..813f0df 100644
--- a/tests/clients/test_fastmcp_client.py
+++ b/tests/clients/test_fastmcp_client.py
@@ -7,12 +7,6 @@ from fastmcp.clients import FastMCPClient
 from fastmcp.server.server import FastMCP
 
 
-class _TestException(Exception):
-    """Test exception for testing raise_exceptions behavior."""
-
-    pass
-
-
 @pytest.fixture
 def fastmcp_server():
     """Fixture that creates a FastMCP server with tools, resources, and prompts."""
@@ -24,11 +18,11 @@ def fastmcp_server():
         """Greet someone by name."""
         return f"Hello, {name}!"
 
-    # Add a tool that raises an exception
+    # Add a second tool
     @server.tool()
-    def error_tool() -> str:
-        """A tool that always raises an exception."""
-        raise _TestException("Deliberate test exception")
+    def add(a: int, b: int) -> int:
+        """Add two numbers together."""
+        return a + b
 
     # Add a resource
     @server.resource(uri="data://users")
@@ -57,9 +51,7 @@ async def test_list_tools(fastmcp_server):
 
         # Check that our tools are available
         assert len(result.tools) == 2
-        tool_names = [tool.name for tool in result.tools]
-        assert "greet" in tool_names
-        assert "error_tool" in tool_names
+        assert set(tool.name for tool in result.tools) == {"greet", "add"}
 
 
 async def test_call_tool(fastmcp_server):
diff --git a/tests/prompts/test_prompt_manager.py b/tests/prompts/test_prompt_manager.py
index 1e8dac1..3a5acce 100644
--- a/tests/prompts/test_prompt_manager.py
+++ b/tests/prompts/test_prompt_manager.py
@@ -44,7 +44,7 @@ def test_import_prompts():
     target_manager = PromptManager()
 
     # Import prompts from source to target
-    prefix = "nlp"
+    prefix = "nlp/"
     target_manager.import_prompts(source_manager, prefix)
 
     # Verify prompts were imported with prefixes
@@ -109,7 +109,7 @@ def test_import_prompts_with_duplicates():
     target_manager._prompts["common"] = target_prompt
 
     # Import prompts with prefix
-    prefix = "external"
+    prefix = "external/"
     target_manager.import_prompts(source_manager, prefix)
 
     # Verify both prompts exist in target manager
@@ -146,10 +146,10 @@ def test_import_prompts_with_nested_prefixes():
     first_manager._prompts["analyze"] = original_prompt
 
     # Import to second manager with prefix
-    second_manager.import_prompts(first_manager, "text")
+    second_manager.import_prompts(first_manager, "text/")
 
     # Import from second to third with another prefix
-    third_manager.import_prompts(second_manager, "ai")
+    third_manager.import_prompts(second_manager, "ai/")
 
     # Verify the nested prefixing
     assert "text/analyze" in second_manager._prompts
diff --git a/tests/resources/test_resource_manager.py b/tests/resources/test_resource_manager.py
index 9ae89d4..6484415 100644
--- a/tests/resources/test_resource_manager.py
+++ b/tests/resources/test_resource_manager.py
@@ -39,7 +39,7 @@ def test_import_resources():
     target_manager = ResourceManager()
 
     # Import resources from source to target
-    prefix = "data"
+    prefix = "data+"
     target_manager.import_resources(source_manager, prefix)
 
     # Verify resources were imported with prefixes
@@ -126,7 +126,7 @@ def test_import_templates():
     target_manager = ResourceManager()
 
     # Import templates from source to target
-    prefix = "shop"
+    prefix = "shop+"
     target_manager.import_templates(source_manager, prefix)
 
     # Verify templates were imported with prefixes
@@ -212,7 +212,7 @@ def test_import_multiple_resource_types():
     target_manager = ResourceManager()
 
     # Import both resources and templates
-    prefix = "test"
+    prefix = "test+"
     target_manager.import_resources(source_manager, prefix)
     target_manager.import_templates(source_manager, prefix)
 
diff --git a/tests/server/test_proxy.py b/tests/server/test_proxy.py
new file mode 100644
index 0000000..62d8b92
--- /dev/null
+++ b/tests/server/test_proxy.py
@@ -0,0 +1,179 @@
+import json
+from typing import Any
+
+import pytest
+from dirty_equals import Contains
+
+from fastmcp import FastMCP
+from fastmcp.clients.fastmcp_client import FastMCPClient
+from fastmcp.server.proxy import FastMCPProxy
+
+USERS = [
+    {"id": "1", "name": "Alice", "active": True},
+    {"id": "2", "name": "Bob", "active": True},
+    {"id": "3", "name": "Charlie", "active": False},
+]
+
+
+@pytest.fixture
+def fastmcp_server():
+    server = FastMCP("TestServer")
+
+    # --- Tools ---
+
+    @server.tool()
+    def greet(name: str) -> str:
+        """Greet someone by name."""
+        return f"Hello, {name}!"
+
+    @server.tool()
+    def add(a: int, b: int) -> int:
+        """Add two numbers together."""
+        return a + b
+
+    @server.tool()
+    def error_tool():
+        """This tool always raises an error."""
+        raise ValueError("This is a test error")
+
+    # --- Resources ---
+
+    @server.resource(uri="resource://wave")
+    def wave() -> str:
+        return "👋"
+
+    @server.resource(uri="data://users")
+    async def get_users() -> list[dict[str, Any]]:
+        return USERS
+
+    @server.resource(uri="data://user/{user_id}")
+    async def get_user(user_id: str) -> dict[str, Any] | None:
+        return next((user for user in USERS if user["id"] == user_id), None)
+
+    # --- Prompts ---
+
+    @server.prompt()
+    def welcome(name: str) -> str:
+        return f"Welcome to FastMCP, {name}!"
+
+    return server
+
+
+@pytest.fixture
+async def proxy_server(fastmcp_server):
+    """Fixture that creates a FastMCP proxy server."""
+    return await FastMCP.as_proxy(FastMCPClient(fastmcp_server))
+
+
+async def test_create_proxy(fastmcp_server):
+    """Test that the proxy server properly forwards requests to the original server."""
+    # Create a client
+    client = FastMCPClient(fastmcp_server)
+
+    server = await FastMCPProxy.from_client(client)
+
+    assert isinstance(server, FastMCP)
+    assert server.name == "FastMCP"
+
+
+class TestTools:
+    async def test_list_tools(self, proxy_server):
+        tools = await proxy_server.list_tools()
+        assert [t.name for t in tools] == Contains("greet", "add", "error_tool")
+
+    async def test_list_tools_same_as_original(self, fastmcp_server, proxy_server):
+        assert await proxy_server.list_tools() == await fastmcp_server.list_tools()
+
+    async def test_call_tool_result_same_as_original(
+        self, fastmcp_server: FastMCP, proxy_server: FastMCPProxy
+    ):
+        result = await fastmcp_server.call_tool("greet", {"name": "Alice"})
+        proxy_result = await proxy_server.call_tool("greet", {"name": "Alice"})
+
+        assert result == proxy_result
+
+    async def test_call_tool_calls_tool(self, proxy_server):
+        proxy_result = await proxy_server.call_tool("add", {"a": 1, "b": 2})
+
+        assert proxy_result[0].text == "3"
+
+    async def test_error_tool_raises_error(self, proxy_server):
+        with pytest.raises(ValueError, match="This is a test error"):
+            await proxy_server.call_tool("error_tool", {})
+
+
+class TestResources:
+    async def test_list_resources(self, proxy_server):
+        resources = await proxy_server.list_resources()
+        assert [r.name for r in resources] == Contains(
+            "data://users", "resource://wave"
+        )
+
+    async def test_list_resources_same_as_original(self, fastmcp_server, proxy_server):
+        assert (
+            await proxy_server.list_resources() == await fastmcp_server.list_resources()
+        )
+
+    async def test_read_resource(self, proxy_server: FastMCPProxy):
+        result = await proxy_server.read_resource("resource://wave")
+        assert result[0].content == "👋"  # type: ignore
+
+    async def test_read_resource_same_as_original(self, fastmcp_server, proxy_server):
+        result = await fastmcp_server.read_resource("resource://wave")
+        proxy_result = await proxy_server.read_resource("resource://wave")
+        assert proxy_result == result
+
+    async def test_read_json_resource(self, proxy_server: FastMCPProxy):
+        result = await proxy_server.read_resource("data://users")
+        assert json.loads(result[0].content) == USERS  # type: ignore
+
+    async def test_read_resource_returns_none_if_not_found(self, proxy_server):
+        with pytest.raises(
+            ValueError, match="Unknown resource: resource://nonexistent"
+        ):
+            await proxy_server.read_resource("resource://nonexistent")
+
+
+class TestResourceTemplates:
+    async def test_list_resource_templates(self, proxy_server):
+        templates = await proxy_server.list_resource_templates()
+        assert [t.name for t in templates] == Contains("get_user")
+
+    async def test_list_resource_templates_same_as_original(
+        self, fastmcp_server, proxy_server
+    ):
+        result = await fastmcp_server.list_resource_templates()
+        proxy_result = await proxy_server.list_resource_templates()
+        assert proxy_result == result
+
+    @pytest.mark.parametrize("id", [1, 2, 3])
+    async def test_read_resource_template(self, proxy_server: FastMCPProxy, id: int):
+        result = await proxy_server.read_resource(f"data://user/{id}")
+        assert json.loads(result[0].content) == USERS[id - 1]  # type: ignore
+
+    async def test_read_resource_template_same_as_original(
+        self, fastmcp_server, proxy_server
+    ):
+        result = await fastmcp_server.read_resource("data://user/1")
+        proxy_result = await proxy_server.read_resource("data://user/1")
+        assert proxy_result == result
+
+
+class TestPrompts:
+    async def test_list_prompts(self, proxy_server):
+        prompts = await proxy_server.list_prompts()
+        assert [p.name for p in prompts] == Contains("welcome")
+
+    async def test_list_prompts_same_as_original(self, fastmcp_server, proxy_server):
+        assert await proxy_server.list_prompts() == await fastmcp_server.list_prompts()
+
+    async def test_render_prompt_same_as_original(
+        self, fastmcp_server: FastMCP, proxy_server
+    ):
+        result = await fastmcp_server.get_prompt("welcome", {"name": "Alice"})
+        proxy_result = await proxy_server.get_prompt("welcome", {"name": "Alice"})
+        assert proxy_result == result
+
+    async def test_render_prompt_calls_prompt(self, proxy_server):
+        result = await proxy_server.get_prompt("welcome", {"name": "Alice"})
+        assert result.messages[0].content.text == "Welcome to FastMCP, Alice!"
diff --git a/tests/tools/test_tool_manager.py b/tests/tools/test_tool_manager.py
index 8a84fff..3accb81 100644
--- a/tests/tools/test_tool_manager.py
+++ b/tests/tools/test_tool_manager.py
@@ -23,7 +23,7 @@ def test_import_tools():
     target_manager = ToolManager()
 
     # Import tools from source to target
-    prefix = "source"
+    prefix = "source/"
     target_manager.import_tools(source_manager, prefix)
 
     # Verify tools were imported with prefixes
@@ -65,7 +65,7 @@ def test_tool_duplicate_behavior():
     )  # Pre-create with the prefixed name
 
     # Import tools from source to target
-    target_manager.import_tools(source_manager, "source")
+    target_manager.import_tools(source_manager, "source/")
 
     # The original tool in the target manager is replaced by the imported one
     assert target_manager._tools["source/common_tool"].fn.__name__ == source_fn.__name__
@@ -89,8 +89,8 @@ def test_import_tools_with_multiple_prefixes():
 
     # Create target manager and import from both sources
     main_manager = ToolManager()
-    main_manager.import_tools(weather_manager, "weather")
-    main_manager.import_tools(news_manager, "news")
+    main_manager.import_tools(weather_manager, "weather/")
+    main_manager.import_tools(news_manager, "news/")
 
     # Verify tools were imported with correct prefixes
     assert "weather/forecast" in main_manager._tools