Jeremiah Lowin commited on
Commit
a038c68
·
unverified ·
2 Parent(s): 92533b37826473

Merge pull request #105 from jlowin/proxy

Browse files
pyproject.toml CHANGED
@@ -32,11 +32,15 @@ dev = [
32
  "copychat>=0.5.2",
33
  "ipython>=8.12.3",
34
  "pdbpp>=0.10.3",
 
35
  ]
36
 
37
  [tool.pytest.ini_options]
38
  asyncio_mode = "auto"
39
  asyncio_default_fixture_loop_scope = "session"
 
 
 
40
 
41
  [tool.hatch.version]
42
  source = "vcs"
 
32
  "copychat>=0.5.2",
33
  "ipython>=8.12.3",
34
  "pdbpp>=0.10.3",
35
+ "dirty-equals>=0.9.0",
36
  ]
37
 
38
  [tool.pytest.ini_options]
39
  asyncio_mode = "auto"
40
  asyncio_default_fixture_loop_scope = "session"
41
+ filterwarnings = [
42
+ "ignore:Accessing the 'model_fields' attribute on the instance is deprecated:DeprecationWarning",
43
+ ]
44
 
45
  [tool.hatch.version]
46
  source = "vcs"
src/fastmcp/clients/base.py CHANGED
@@ -165,16 +165,22 @@ class BaseClient(abc.ABC):
165
  """Send a resources/listResourceTemplates request."""
166
  return await self.session.list_resource_templates()
167
 
168
- async def read_resource(self, uri: AnyUrl) -> mcp.types.ReadResourceResult:
169
  """Send a resources/read request."""
 
 
170
  return await self.session.read_resource(uri)
171
 
172
- async def subscribe_resource(self, uri: AnyUrl) -> None:
173
  """Send a resources/subscribe request."""
 
 
174
  await self.session.subscribe_resource(uri)
175
 
176
- async def unsubscribe_resource(self, uri: AnyUrl) -> None:
177
  """Send a resources/unsubscribe request."""
 
 
178
  await self.session.unsubscribe_resource(uri)
179
 
180
  async def list_prompts(self) -> mcp.types.ListPromptsResult:
 
165
  """Send a resources/listResourceTemplates request."""
166
  return await self.session.list_resource_templates()
167
 
168
+ async def read_resource(self, uri: AnyUrl | str) -> mcp.types.ReadResourceResult:
169
  """Send a resources/read request."""
170
+ if isinstance(uri, str):
171
+ uri = AnyUrl(uri)
172
  return await self.session.read_resource(uri)
173
 
174
+ async def subscribe_resource(self, uri: AnyUrl | str) -> None:
175
  """Send a resources/subscribe request."""
176
+ if isinstance(uri, str):
177
+ uri = AnyUrl(uri)
178
  await self.session.subscribe_resource(uri)
179
 
180
+ async def unsubscribe_resource(self, uri: AnyUrl | str) -> None:
181
  """Send a resources/unsubscribe request."""
182
+ if isinstance(uri, str):
183
+ uri = AnyUrl(uri)
184
  await self.session.unsubscribe_resource(uri)
185
 
186
  async def list_prompts(self) -> mcp.types.ListPromptsResult:
src/fastmcp/prompts/prompt_manager.py CHANGED
@@ -11,20 +11,23 @@ class PromptManager(BasePromptManager):
11
  Adds ability to import prompts from other managers with prefixed names.
12
  """
13
 
14
- def import_prompts(self, manager: "PromptManager", prefix: str) -> None:
 
 
15
  """
16
  Import all prompts from another PromptManager with prefixed names.
17
 
18
  Args:
19
  manager: Another PromptManager instance to import prompts from
20
  prefix: Prefix to add to prompt names. The resulting prompt name will
21
- be in the format "{prefix}/{original_name}"
22
- For example, with prefix "weather" and prompt "forecast_prompt",
 
23
  the imported prompt would be available as "weather/forecast_prompt"
24
  """
25
  for name, prompt in manager._prompts.items():
26
  # Create prefixed name - we keep the original name in the Prompt object
27
- prefixed_name = f"{prefix}/{name}"
28
 
29
  # Log the import
30
  logger.debug(f"Importing prompt with name {name} as {prefixed_name}")
 
11
  Adds ability to import prompts from other managers with prefixed names.
12
  """
13
 
14
+ def import_prompts(
15
+ self, manager: "PromptManager", prefix: str | None = None
16
+ ) -> None:
17
  """
18
  Import all prompts from another PromptManager with prefixed names.
19
 
20
  Args:
21
  manager: Another PromptManager instance to import prompts from
22
  prefix: Prefix to add to prompt names. The resulting prompt name will
23
+ be in the format "{prefix}{original_name}" if prefix is provided,
24
+ otherwise the original name is used.
25
+ For example, with prefix "weather/" and prompt "forecast_prompt",
26
  the imported prompt would be available as "weather/forecast_prompt"
27
  """
28
  for name, prompt in manager._prompts.items():
29
  # Create prefixed name - we keep the original name in the Prompt object
30
+ prefixed_name = f"{prefix}{name}" if prefix else name
31
 
32
  # Log the import
33
  logger.debug(f"Importing prompt with name {name} as {prefixed_name}")
src/fastmcp/resources/resource_manager.py CHANGED
@@ -10,20 +10,25 @@ logger = logging.getLogger(__name__)
10
  class ResourceManager(BaseResourceManager):
11
  """ResourceManager that adds methods to import resources from other managers."""
12
 
13
- def import_resources(self, manager: "ResourceManager", prefix: str) -> None:
 
 
14
  """Import resources from another resource manager.
15
 
16
- Resources are imported with a prefixed URI. For example, if a resource has
17
- URI "data://users" and you import it with prefix "app", the imported resource
18
- will have URI "app+data://users".
 
19
 
20
  Args:
21
  manager: The ResourceManager to import from
22
- prefix: A prefix to apply to the resource URIs
 
 
23
  """
24
  for uri, resource in manager._resources.items():
25
  # Create prefixed URI and copy the resource with the new URI
26
- prefixed_uri = f"{prefix}+{uri}"
27
 
28
  # Log the import
29
  logger.debug(f"Importing resource with URI {uri} as {prefixed_uri}")
@@ -31,20 +36,27 @@ class ResourceManager(BaseResourceManager):
31
  # Store directly in resources dictionary
32
  self._resources[prefixed_uri] = resource
33
 
34
- def import_templates(self, manager: "ResourceManager", prefix: str) -> None:
 
 
35
  """Import resource templates from another resource manager.
36
 
37
- Templates are imported with a prefixed URI template. For example, if a template has
38
- URI template "data://users/{id}" and you import it with prefix "app", the
39
- imported template will have URI template "app+data://users/{id}".
 
40
 
41
  Args:
42
  manager: The ResourceManager to import templates from
43
- prefix: A prefix to apply to the template URIs
 
 
44
  """
45
  for uri_template, template in manager._templates.items():
46
  # Create prefixed URI template and copy the template with the new URI template
47
- prefixed_uri_template = f"{prefix}+{uri_template}"
 
 
48
 
49
  # Log the import
50
  logger.debug(
 
10
  class ResourceManager(BaseResourceManager):
11
  """ResourceManager that adds methods to import resources from other managers."""
12
 
13
+ def import_resources(
14
+ self, manager: "ResourceManager", prefix: str | None = None
15
+ ) -> None:
16
  """Import resources from another resource manager.
17
 
18
+ Resources are imported with a prefixed URI if a prefix is provided. For example,
19
+ if a resource has URI "data://users" and you import it with prefix "app+", the
20
+ imported resource will have URI "app+data://users". If no prefix is provided,
21
+ the original URI is used.
22
 
23
  Args:
24
  manager: The ResourceManager to import from
25
+ prefix: A prefix to apply to the resource URIs, including the delimiter.
26
+ For example, "app+" would result in URIs like "app+data://users".
27
+ If None, the original URI is used.
28
  """
29
  for uri, resource in manager._resources.items():
30
  # Create prefixed URI and copy the resource with the new URI
31
+ prefixed_uri = f"{prefix}{uri}" if prefix else uri
32
 
33
  # Log the import
34
  logger.debug(f"Importing resource with URI {uri} as {prefixed_uri}")
 
36
  # Store directly in resources dictionary
37
  self._resources[prefixed_uri] = resource
38
 
39
+ def import_templates(
40
+ self, manager: "ResourceManager", prefix: str | None = None
41
+ ) -> None:
42
  """Import resource templates from another resource manager.
43
 
44
+ Templates are imported with a prefixed URI template if a prefix is provided.
45
+ For example, if a template has URI template "data://users/{id}" and you import
46
+ it with prefix "app+", the imported template will have URI template
47
+ "app+data://users/{id}". If no prefix is provided, the original URI template is used.
48
 
49
  Args:
50
  manager: The ResourceManager to import templates from
51
+ prefix: A prefix to apply to the template URIs, including the delimiter.
52
+ For example, "app+" would result in URI templates like "app+data://users/{id}".
53
+ If None, the original URI template is used.
54
  """
55
  for uri_template, template in manager._templates.items():
56
  # Create prefixed URI template and copy the template with the new URI template
57
+ prefixed_uri_template = (
58
+ f"{prefix}{uri_template}" if prefix else uri_template
59
+ )
60
 
61
  # Log the import
62
  logger.debug(
src/fastmcp/server/proxy.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, cast
2
+
3
+ import mcp.types
4
+ from mcp.server.fastmcp.prompts import Prompt
5
+ from mcp.server.fastmcp.resources import Resource, ResourceTemplate
6
+ from mcp.server.fastmcp.tools.base import Tool
7
+ from mcp.server.fastmcp.utilities.func_metadata import func_metadata
8
+ from mcp.types import BlobResourceContents, PromptMessage, TextResourceContents
9
+
10
+ from fastmcp.clients.base import BaseClient
11
+ from fastmcp.server.context import Context
12
+ from fastmcp.server.server import FastMCP
13
+ from fastmcp.utilities.logging import get_logger
14
+
15
+ logger = get_logger(__name__)
16
+
17
+
18
+ def _proxy_passthrough():
19
+ pass
20
+
21
+
22
+ class ProxyTool(Tool):
23
+ def __init__(self, client: "BaseClient", **kwargs):
24
+ super().__init__(**kwargs)
25
+ self._client = client
26
+
27
+ @classmethod
28
+ async def from_client(
29
+ cls, client: "BaseClient", tool: mcp.types.Tool
30
+ ) -> "ProxyTool":
31
+ return cls(
32
+ client=client,
33
+ name=tool.name,
34
+ description=tool.description,
35
+ parameters=tool.inputSchema,
36
+ fn=_proxy_passthrough,
37
+ fn_metadata=func_metadata(_proxy_passthrough),
38
+ is_async=True,
39
+ )
40
+
41
+ async def run(
42
+ self, arguments: dict[str, Any], context: Context | None = None
43
+ ) -> Any:
44
+ async with self._client:
45
+ result = await self._client.call_tool(self.name, arguments)
46
+ if result.isError:
47
+ raise ValueError(cast(mcp.types.TextContent, result.content[0]).text)
48
+ return result.content[0]
49
+
50
+
51
+ class ProxyResource(Resource):
52
+ def __init__(
53
+ self, client: "BaseClient", *, _value: str | bytes | None = None, **kwargs
54
+ ):
55
+ super().__init__(**kwargs)
56
+ self._client = client
57
+ self._value = _value
58
+
59
+ @classmethod
60
+ async def from_client(
61
+ cls, client: "BaseClient", resource: mcp.types.Resource
62
+ ) -> "ProxyResource":
63
+ return cls(
64
+ client=client,
65
+ uri=resource.uri,
66
+ name=resource.name,
67
+ description=resource.description,
68
+ mime_type=resource.mimeType,
69
+ )
70
+
71
+ async def read(self) -> str | bytes:
72
+ if self._value is not None:
73
+ return self._value
74
+
75
+ async with self._client:
76
+ result = await self._client.read_resource(self.uri)
77
+ if isinstance(result.contents[0], TextResourceContents):
78
+ return result.contents[0].text
79
+ elif isinstance(result.contents[0], BlobResourceContents):
80
+ return result.contents[0].blob
81
+ else:
82
+ raise ValueError(f"Unsupported content type: {type(result.contents[0])}")
83
+
84
+
85
+ class ProxyTemplate(ResourceTemplate):
86
+ def __init__(self, client: "BaseClient", **kwargs):
87
+ super().__init__(**kwargs)
88
+ self._client = client
89
+
90
+ @classmethod
91
+ async def from_client(
92
+ cls, client: "BaseClient", template: mcp.types.ResourceTemplate
93
+ ) -> "ProxyTemplate":
94
+ return cls(
95
+ client=client,
96
+ uri_template=template.uriTemplate,
97
+ name=template.name,
98
+ description=template.description,
99
+ fn=_proxy_passthrough,
100
+ parameters={},
101
+ )
102
+
103
+ async def create_resource(self, uri: str, params: dict[str, Any]) -> ProxyResource:
104
+ async with self._client:
105
+ result = await self._client.read_resource(uri)
106
+
107
+ if isinstance(result.contents[0], TextResourceContents):
108
+ value = result.contents[0].text
109
+ elif isinstance(result.contents[0], BlobResourceContents):
110
+ value = result.contents[0].blob
111
+ else:
112
+ raise ValueError(f"Unsupported content type: {type(result.contents[0])}")
113
+
114
+ return ProxyResource(
115
+ client=self._client,
116
+ uri=uri,
117
+ name=self.name,
118
+ description=self.description,
119
+ mime_type=result.contents[0].mimeType,
120
+ contents=result.contents,
121
+ _value=value,
122
+ )
123
+
124
+
125
+ class ProxyPrompt(Prompt):
126
+ def __init__(self, client: "BaseClient", **kwargs):
127
+ super().__init__(**kwargs)
128
+ self._client = client
129
+
130
+ @classmethod
131
+ async def from_client(
132
+ cls, client: "BaseClient", prompt: mcp.types.Prompt
133
+ ) -> "ProxyPrompt":
134
+ return cls(
135
+ client=client,
136
+ name=prompt.name,
137
+ description=prompt.description,
138
+ arguments=[a.model_dump() for a in prompt.arguments or []],
139
+ fn=_proxy_passthrough,
140
+ )
141
+
142
+ async def render(self, arguments: dict[str, Any]) -> list[PromptMessage]:
143
+ async with self._client:
144
+ result = await self._client.get_prompt(self.name, arguments)
145
+ return result.messages
146
+
147
+
148
+ class FastMCPProxy(FastMCP):
149
+ def __init__(self, _async_constructor: bool, **kwargs):
150
+ if not _async_constructor:
151
+ raise ValueError(
152
+ "FastMCPProxy() was initialied unexpectedly. Please use a constructor like `FastMCPProxy.from_client()` instead."
153
+ )
154
+ super().__init__(**kwargs)
155
+
156
+ @classmethod
157
+ async def from_client(
158
+ cls, client: "BaseClient", name: str | None = None, **settings: Any
159
+ ) -> "FastMCPProxy":
160
+ """Create a FastMCP proxy server from a client.
161
+
162
+ This method creates a new FastMCP server instance that proxies requests to the provided client.
163
+ It discovers the client's tools, resources, prompts, and templates, and creates corresponding
164
+ components in the server that forward requests to the client.
165
+
166
+ Args:
167
+ client: The client to proxy requests to
168
+ name: Optional name for the new FastMCP server (defaults to client name if available)
169
+ **settings: Additional settings for the FastMCP server
170
+
171
+ Returns:
172
+ A FastMCP server that proxies requests to the client
173
+ """
174
+ server = cls(name=name, **settings, _async_constructor=True)
175
+
176
+ async with client:
177
+ # Register proxies for client tools
178
+ tools_result = await client.list_tools()
179
+ for tool in tools_result.tools:
180
+ tool_proxy = await ProxyTool.from_client(client, tool)
181
+ server._tool_manager._tools[tool_proxy.name] = tool_proxy
182
+ logger.debug(f"Created proxy for tool: {tool_proxy.name}")
183
+
184
+ # Register proxies for client resources
185
+ resources_result = await client.list_resources()
186
+ for resource in resources_result.resources:
187
+ resource_proxy = await ProxyResource.from_client(client, resource)
188
+ server._resource_manager._resources[str(resource_proxy.uri)] = (
189
+ resource_proxy
190
+ )
191
+ logger.debug(f"Created proxy for resource: {resource_proxy.uri}")
192
+
193
+ # Register proxies for client resource templates
194
+ templates_result = await client.list_resource_templates()
195
+ for template in templates_result.resourceTemplates:
196
+ template_proxy = await ProxyTemplate.from_client(client, template)
197
+ server._resource_manager._templates[template_proxy.uri_template] = (
198
+ template_proxy
199
+ )
200
+ logger.debug(
201
+ f"Created proxy for template: {template_proxy.uri_template}"
202
+ )
203
+
204
+ # Register proxies for client prompts
205
+ prompts_result = await client.list_prompts()
206
+ for prompt in prompts_result.prompts:
207
+ prompt_proxy = await ProxyPrompt.from_client(client, prompt)
208
+ server._prompt_manager._prompts[prompt_proxy.name] = prompt_proxy
209
+ logger.debug(f"Created proxy for prompt: {prompt_proxy.name}")
210
+
211
+ logger.info(f"Created server '{server.name}' proxying to client: {client}")
212
+ return server
src/fastmcp/server/server.py CHANGED
@@ -1,4 +1,4 @@
1
- from typing import Any, Dict
2
 
3
  import mcp.server.fastmcp
4
  import mcp.types
@@ -9,6 +9,11 @@ from fastmcp.server.context import Context
9
  from fastmcp.tools.tool_manager import ToolManager
10
  from fastmcp.utilities.logging import get_logger
11
 
 
 
 
 
 
12
  logger = get_logger(__name__)
13
 
14
 
@@ -62,20 +67,41 @@ class FastMCP(mcp.server.fastmcp.FastMCP):
62
  # Mount the app in the list of mounted apps
63
  self._mounted_apps[prefix] = app
64
 
65
- # Import tools from the mounted app
66
- self._tool_manager.import_tools(app._tool_manager, prefix)
67
-
68
- # Import resources from the mounted app
69
- self._resource_manager.import_resources(app._resource_manager, prefix)
70
 
71
- # Import resource templates
72
- self._resource_manager.import_templates(app._resource_manager, prefix)
 
 
73
 
74
- # Import prompts
75
- self._prompt_manager.import_prompts(app._prompt_manager, prefix)
 
76
 
77
  logger.info(f"Mounted app with prefix '{prefix}'")
78
- logger.debug(f"Imported tools with prefix '{prefix}/'")
79
- logger.debug(f"Imported resources with prefix '{prefix}+'")
80
- logger.debug(f"Imported templates with prefix '{prefix}+'")
81
- logger.debug(f"Imported prompts with prefix '{prefix}/'")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import TYPE_CHECKING, Any, Dict
2
 
3
  import mcp.server.fastmcp
4
  import mcp.types
 
9
  from fastmcp.tools.tool_manager import ToolManager
10
  from fastmcp.utilities.logging import get_logger
11
 
12
+ if TYPE_CHECKING:
13
+ from fastmcp.clients.base import BaseClient
14
+
15
+ from .proxy import FastMCPProxy
16
+
17
  logger = get_logger(__name__)
18
 
19
 
 
67
  # Mount the app in the list of mounted apps
68
  self._mounted_apps[prefix] = app
69
 
70
+ # Import tools from the mounted app with / delimiter
71
+ tool_prefix = f"{prefix}/"
72
+ self._tool_manager.import_tools(app._tool_manager, tool_prefix)
 
 
73
 
74
+ # Import resources and templates from the mounted app with + delimiter
75
+ resource_prefix = f"{prefix}+"
76
+ self._resource_manager.import_resources(app._resource_manager, resource_prefix)
77
+ self._resource_manager.import_templates(app._resource_manager, resource_prefix)
78
 
79
+ # Import prompts with / delimiter
80
+ prompt_prefix = f"{prefix}/"
81
+ self._prompt_manager.import_prompts(app._prompt_manager, prompt_prefix)
82
 
83
  logger.info(f"Mounted app with prefix '{prefix}'")
84
+ logger.debug(f"Imported tools with prefix '{tool_prefix}'")
85
+ logger.debug(f"Imported resources with prefix '{resource_prefix}'")
86
+ logger.debug(f"Imported templates with prefix '{resource_prefix}'")
87
+ logger.debug(f"Imported prompts with prefix '{prompt_prefix}'")
88
+
89
+ @classmethod
90
+ async def as_proxy(cls, client: "BaseClient", **settings: Any) -> "FastMCPProxy":
91
+ """
92
+ Create a FastMCP proxy server from a client.
93
+
94
+ This method creates a new FastMCP server instance that proxies requests to the provided client.
95
+ It discovers the client's tools, resources, prompts, and templates, and creates corresponding
96
+ components in the server that forward requests to the client.
97
+
98
+ Args:
99
+ client: The client to proxy requests to
100
+ **settings: Additional settings for the FastMCP server
101
+
102
+ Returns:
103
+ A FastMCP server that proxies requests to the client
104
+ """
105
+ from .proxy import FastMCPProxy
106
+
107
+ return await FastMCPProxy.from_client(client=client, **settings)
src/fastmcp/tools/tool_manager.py CHANGED
@@ -12,19 +12,22 @@ class ToolManager(mcp.server.fastmcp.tools.ToolManager):
12
  Adds ability to import tools from other managers with prefixed names.
13
  """
14
 
15
- def import_tools(self, tool_manager: "ToolManager", prefix: str) -> None:
 
 
16
  """
17
  Import all tools from another ToolManager with prefixed names.
18
 
19
  Args:
20
  tool_manager: Another ToolManager instance to import tools from
21
- prefix: Prefix to add to tool names. The resulting tool name will
22
- be in the format "{prefix}/{original_name}"
23
- For example, with prefix "weather" and tool "forecast",
 
24
  the imported tool would be available as "weather/forecast"
25
  """
26
  for name, tool in tool_manager._tools.items():
27
- prefixed_name = f"{prefix}/{name}"
28
 
29
  # Create a shallow copy of the tool with the prefixed name
30
  copied_tool = Tool.from_function(
 
12
  Adds ability to import tools from other managers with prefixed names.
13
  """
14
 
15
+ def import_tools(
16
+ self, tool_manager: "ToolManager", prefix: str | None = None
17
+ ) -> None:
18
  """
19
  Import all tools from another ToolManager with prefixed names.
20
 
21
  Args:
22
  tool_manager: Another ToolManager instance to import tools from
23
+ prefix: Prefix to add to tool names, including the delimiter.
24
+ The resulting tool name will be in the format "{prefix}{original_name}"
25
+ if prefix is provided, otherwise the original name is used.
26
+ For example, with prefix "weather/" and tool "forecast",
27
  the imported tool would be available as "weather/forecast"
28
  """
29
  for name, tool in tool_manager._tools.items():
30
+ prefixed_name = f"{prefix}{name}" if prefix else name
31
 
32
  # Create a shallow copy of the tool with the prefixed name
33
  copied_tool = Tool.from_function(
tests/clients/test_fastmcp_client.py CHANGED
@@ -7,12 +7,6 @@ from fastmcp.clients import FastMCPClient
7
  from fastmcp.server.server import FastMCP
8
 
9
 
10
- class _TestException(Exception):
11
- """Test exception for testing raise_exceptions behavior."""
12
-
13
- pass
14
-
15
-
16
  @pytest.fixture
17
  def fastmcp_server():
18
  """Fixture that creates a FastMCP server with tools, resources, and prompts."""
@@ -24,11 +18,11 @@ def fastmcp_server():
24
  """Greet someone by name."""
25
  return f"Hello, {name}!"
26
 
27
- # Add a tool that raises an exception
28
  @server.tool()
29
- def error_tool() -> str:
30
- """A tool that always raises an exception."""
31
- raise _TestException("Deliberate test exception")
32
 
33
  # Add a resource
34
  @server.resource(uri="data://users")
@@ -57,9 +51,7 @@ async def test_list_tools(fastmcp_server):
57
 
58
  # Check that our tools are available
59
  assert len(result.tools) == 2
60
- tool_names = [tool.name for tool in result.tools]
61
- assert "greet" in tool_names
62
- assert "error_tool" in tool_names
63
 
64
 
65
  async def test_call_tool(fastmcp_server):
 
7
  from fastmcp.server.server import FastMCP
8
 
9
 
 
 
 
 
 
 
10
  @pytest.fixture
11
  def fastmcp_server():
12
  """Fixture that creates a FastMCP server with tools, resources, and prompts."""
 
18
  """Greet someone by name."""
19
  return f"Hello, {name}!"
20
 
21
+ # Add a second tool
22
  @server.tool()
23
+ def add(a: int, b: int) -> int:
24
+ """Add two numbers together."""
25
+ return a + b
26
 
27
  # Add a resource
28
  @server.resource(uri="data://users")
 
51
 
52
  # Check that our tools are available
53
  assert len(result.tools) == 2
54
+ assert set(tool.name for tool in result.tools) == {"greet", "add"}
 
 
55
 
56
 
57
  async def test_call_tool(fastmcp_server):
tests/prompts/test_prompt_manager.py CHANGED
@@ -44,7 +44,7 @@ def test_import_prompts():
44
  target_manager = PromptManager()
45
 
46
  # Import prompts from source to target
47
- prefix = "nlp"
48
  target_manager.import_prompts(source_manager, prefix)
49
 
50
  # Verify prompts were imported with prefixes
@@ -109,7 +109,7 @@ def test_import_prompts_with_duplicates():
109
  target_manager._prompts["common"] = target_prompt
110
 
111
  # Import prompts with prefix
112
- prefix = "external"
113
  target_manager.import_prompts(source_manager, prefix)
114
 
115
  # Verify both prompts exist in target manager
@@ -146,10 +146,10 @@ def test_import_prompts_with_nested_prefixes():
146
  first_manager._prompts["analyze"] = original_prompt
147
 
148
  # Import to second manager with prefix
149
- second_manager.import_prompts(first_manager, "text")
150
 
151
  # Import from second to third with another prefix
152
- third_manager.import_prompts(second_manager, "ai")
153
 
154
  # Verify the nested prefixing
155
  assert "text/analyze" in second_manager._prompts
 
44
  target_manager = PromptManager()
45
 
46
  # Import prompts from source to target
47
+ prefix = "nlp/"
48
  target_manager.import_prompts(source_manager, prefix)
49
 
50
  # Verify prompts were imported with prefixes
 
109
  target_manager._prompts["common"] = target_prompt
110
 
111
  # Import prompts with prefix
112
+ prefix = "external/"
113
  target_manager.import_prompts(source_manager, prefix)
114
 
115
  # Verify both prompts exist in target manager
 
146
  first_manager._prompts["analyze"] = original_prompt
147
 
148
  # Import to second manager with prefix
149
+ second_manager.import_prompts(first_manager, "text/")
150
 
151
  # Import from second to third with another prefix
152
+ third_manager.import_prompts(second_manager, "ai/")
153
 
154
  # Verify the nested prefixing
155
  assert "text/analyze" in second_manager._prompts
tests/resources/test_resource_manager.py CHANGED
@@ -39,7 +39,7 @@ def test_import_resources():
39
  target_manager = ResourceManager()
40
 
41
  # Import resources from source to target
42
- prefix = "data"
43
  target_manager.import_resources(source_manager, prefix)
44
 
45
  # Verify resources were imported with prefixes
@@ -126,7 +126,7 @@ def test_import_templates():
126
  target_manager = ResourceManager()
127
 
128
  # Import templates from source to target
129
- prefix = "shop"
130
  target_manager.import_templates(source_manager, prefix)
131
 
132
  # Verify templates were imported with prefixes
@@ -212,7 +212,7 @@ def test_import_multiple_resource_types():
212
  target_manager = ResourceManager()
213
 
214
  # Import both resources and templates
215
- prefix = "test"
216
  target_manager.import_resources(source_manager, prefix)
217
  target_manager.import_templates(source_manager, prefix)
218
 
 
39
  target_manager = ResourceManager()
40
 
41
  # Import resources from source to target
42
+ prefix = "data+"
43
  target_manager.import_resources(source_manager, prefix)
44
 
45
  # Verify resources were imported with prefixes
 
126
  target_manager = ResourceManager()
127
 
128
  # Import templates from source to target
129
+ prefix = "shop+"
130
  target_manager.import_templates(source_manager, prefix)
131
 
132
  # Verify templates were imported with prefixes
 
212
  target_manager = ResourceManager()
213
 
214
  # Import both resources and templates
215
+ prefix = "test+"
216
  target_manager.import_resources(source_manager, prefix)
217
  target_manager.import_templates(source_manager, prefix)
218
 
tests/server/test_proxy.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from typing import Any
3
+
4
+ import pytest
5
+ from dirty_equals import Contains
6
+
7
+ from fastmcp import FastMCP
8
+ from fastmcp.clients.fastmcp_client import FastMCPClient
9
+ from fastmcp.server.proxy import FastMCPProxy
10
+
11
+ USERS = [
12
+ {"id": "1", "name": "Alice", "active": True},
13
+ {"id": "2", "name": "Bob", "active": True},
14
+ {"id": "3", "name": "Charlie", "active": False},
15
+ ]
16
+
17
+
18
+ @pytest.fixture
19
+ def fastmcp_server():
20
+ server = FastMCP("TestServer")
21
+
22
+ # --- Tools ---
23
+
24
+ @server.tool()
25
+ def greet(name: str) -> str:
26
+ """Greet someone by name."""
27
+ return f"Hello, {name}!"
28
+
29
+ @server.tool()
30
+ def add(a: int, b: int) -> int:
31
+ """Add two numbers together."""
32
+ return a + b
33
+
34
+ @server.tool()
35
+ def error_tool():
36
+ """This tool always raises an error."""
37
+ raise ValueError("This is a test error")
38
+
39
+ # --- Resources ---
40
+
41
+ @server.resource(uri="resource://wave")
42
+ def wave() -> str:
43
+ return "👋"
44
+
45
+ @server.resource(uri="data://users")
46
+ async def get_users() -> list[dict[str, Any]]:
47
+ return USERS
48
+
49
+ @server.resource(uri="data://user/{user_id}")
50
+ async def get_user(user_id: str) -> dict[str, Any] | None:
51
+ return next((user for user in USERS if user["id"] == user_id), None)
52
+
53
+ # --- Prompts ---
54
+
55
+ @server.prompt()
56
+ def welcome(name: str) -> str:
57
+ return f"Welcome to FastMCP, {name}!"
58
+
59
+ return server
60
+
61
+
62
+ @pytest.fixture
63
+ async def proxy_server(fastmcp_server):
64
+ """Fixture that creates a FastMCP proxy server."""
65
+ return await FastMCP.as_proxy(FastMCPClient(fastmcp_server))
66
+
67
+
68
+ async def test_create_proxy(fastmcp_server):
69
+ """Test that the proxy server properly forwards requests to the original server."""
70
+ # Create a client
71
+ client = FastMCPClient(fastmcp_server)
72
+
73
+ server = await FastMCPProxy.from_client(client)
74
+
75
+ assert isinstance(server, FastMCP)
76
+ assert server.name == "FastMCP"
77
+
78
+
79
+ class TestTools:
80
+ async def test_list_tools(self, proxy_server):
81
+ tools = await proxy_server.list_tools()
82
+ assert [t.name for t in tools] == Contains("greet", "add", "error_tool")
83
+
84
+ async def test_list_tools_same_as_original(self, fastmcp_server, proxy_server):
85
+ assert await proxy_server.list_tools() == await fastmcp_server.list_tools()
86
+
87
+ async def test_call_tool_result_same_as_original(
88
+ self, fastmcp_server: FastMCP, proxy_server: FastMCPProxy
89
+ ):
90
+ result = await fastmcp_server.call_tool("greet", {"name": "Alice"})
91
+ proxy_result = await proxy_server.call_tool("greet", {"name": "Alice"})
92
+
93
+ assert result == proxy_result
94
+
95
+ async def test_call_tool_calls_tool(self, proxy_server):
96
+ proxy_result = await proxy_server.call_tool("add", {"a": 1, "b": 2})
97
+
98
+ assert proxy_result[0].text == "3"
99
+
100
+ async def test_error_tool_raises_error(self, proxy_server):
101
+ with pytest.raises(ValueError, match="This is a test error"):
102
+ await proxy_server.call_tool("error_tool", {})
103
+
104
+
105
+ class TestResources:
106
+ async def test_list_resources(self, proxy_server):
107
+ resources = await proxy_server.list_resources()
108
+ assert [r.name for r in resources] == Contains(
109
+ "data://users", "resource://wave"
110
+ )
111
+
112
+ async def test_list_resources_same_as_original(self, fastmcp_server, proxy_server):
113
+ assert (
114
+ await proxy_server.list_resources() == await fastmcp_server.list_resources()
115
+ )
116
+
117
+ async def test_read_resource(self, proxy_server: FastMCPProxy):
118
+ result = await proxy_server.read_resource("resource://wave")
119
+ assert result[0].content == "👋" # type: ignore
120
+
121
+ async def test_read_resource_same_as_original(self, fastmcp_server, proxy_server):
122
+ result = await fastmcp_server.read_resource("resource://wave")
123
+ proxy_result = await proxy_server.read_resource("resource://wave")
124
+ assert proxy_result == result
125
+
126
+ async def test_read_json_resource(self, proxy_server: FastMCPProxy):
127
+ result = await proxy_server.read_resource("data://users")
128
+ assert json.loads(result[0].content) == USERS # type: ignore
129
+
130
+ async def test_read_resource_returns_none_if_not_found(self, proxy_server):
131
+ with pytest.raises(
132
+ ValueError, match="Unknown resource: resource://nonexistent"
133
+ ):
134
+ await proxy_server.read_resource("resource://nonexistent")
135
+
136
+
137
+ class TestResourceTemplates:
138
+ async def test_list_resource_templates(self, proxy_server):
139
+ templates = await proxy_server.list_resource_templates()
140
+ assert [t.name for t in templates] == Contains("get_user")
141
+
142
+ async def test_list_resource_templates_same_as_original(
143
+ self, fastmcp_server, proxy_server
144
+ ):
145
+ result = await fastmcp_server.list_resource_templates()
146
+ proxy_result = await proxy_server.list_resource_templates()
147
+ assert proxy_result == result
148
+
149
+ @pytest.mark.parametrize("id", [1, 2, 3])
150
+ async def test_read_resource_template(self, proxy_server: FastMCPProxy, id: int):
151
+ result = await proxy_server.read_resource(f"data://user/{id}")
152
+ assert json.loads(result[0].content) == USERS[id - 1] # type: ignore
153
+
154
+ async def test_read_resource_template_same_as_original(
155
+ self, fastmcp_server, proxy_server
156
+ ):
157
+ result = await fastmcp_server.read_resource("data://user/1")
158
+ proxy_result = await proxy_server.read_resource("data://user/1")
159
+ assert proxy_result == result
160
+
161
+
162
+ class TestPrompts:
163
+ async def test_list_prompts(self, proxy_server):
164
+ prompts = await proxy_server.list_prompts()
165
+ assert [p.name for p in prompts] == Contains("welcome")
166
+
167
+ async def test_list_prompts_same_as_original(self, fastmcp_server, proxy_server):
168
+ assert await proxy_server.list_prompts() == await fastmcp_server.list_prompts()
169
+
170
+ async def test_render_prompt_same_as_original(
171
+ self, fastmcp_server: FastMCP, proxy_server
172
+ ):
173
+ result = await fastmcp_server.get_prompt("welcome", {"name": "Alice"})
174
+ proxy_result = await proxy_server.get_prompt("welcome", {"name": "Alice"})
175
+ assert proxy_result == result
176
+
177
+ async def test_render_prompt_calls_prompt(self, proxy_server):
178
+ result = await proxy_server.get_prompt("welcome", {"name": "Alice"})
179
+ assert result.messages[0].content.text == "Welcome to FastMCP, Alice!"
tests/tools/test_tool_manager.py CHANGED
@@ -23,7 +23,7 @@ def test_import_tools():
23
  target_manager = ToolManager()
24
 
25
  # Import tools from source to target
26
- prefix = "source"
27
  target_manager.import_tools(source_manager, prefix)
28
 
29
  # Verify tools were imported with prefixes
@@ -65,7 +65,7 @@ def test_tool_duplicate_behavior():
65
  ) # Pre-create with the prefixed name
66
 
67
  # Import tools from source to target
68
- target_manager.import_tools(source_manager, "source")
69
 
70
  # The original tool in the target manager is replaced by the imported one
71
  assert target_manager._tools["source/common_tool"].fn.__name__ == source_fn.__name__
@@ -89,8 +89,8 @@ def test_import_tools_with_multiple_prefixes():
89
 
90
  # Create target manager and import from both sources
91
  main_manager = ToolManager()
92
- main_manager.import_tools(weather_manager, "weather")
93
- main_manager.import_tools(news_manager, "news")
94
 
95
  # Verify tools were imported with correct prefixes
96
  assert "weather/forecast" in main_manager._tools
 
23
  target_manager = ToolManager()
24
 
25
  # Import tools from source to target
26
+ prefix = "source/"
27
  target_manager.import_tools(source_manager, prefix)
28
 
29
  # Verify tools were imported with prefixes
 
65
  ) # Pre-create with the prefixed name
66
 
67
  # Import tools from source to target
68
+ target_manager.import_tools(source_manager, "source/")
69
 
70
  # The original tool in the target manager is replaced by the imported one
71
  assert target_manager._tools["source/common_tool"].fn.__name__ == source_fn.__name__
 
89
 
90
  # Create target manager and import from both sources
91
  main_manager = ToolManager()
92
+ main_manager.import_tools(weather_manager, "weather/")
93
+ main_manager.import_tools(news_manager, "news/")
94
 
95
  # Verify tools were imported with correct prefixes
96
  assert "weather/forecast" in main_manager._tools
uv.lock CHANGED
@@ -169,6 +169,15 @@ wheels = [
169
  { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190 },
170
  ]
171
 
 
 
 
 
 
 
 
 
 
172
  [[package]]
173
  name = "distlib"
174
  version = "0.3.9"
@@ -220,7 +229,7 @@ wheels = [
220
 
221
  [[package]]
222
  name = "fastmcp"
223
- version = "0.4.2.dev21+g0b6e56c.d20250407"
224
  source = { editable = "." }
225
  dependencies = [
226
  { name = "mcp" },
@@ -232,6 +241,7 @@ dependencies = [
232
  [package.dev-dependencies]
233
  dev = [
234
  { name = "copychat" },
 
235
  { name = "ipython" },
236
  { name = "pdbpp" },
237
  { name = "pre-commit" },
@@ -254,6 +264,7 @@ requires-dist = [
254
  [package.metadata.requires-dev]
255
  dev = [
256
  { name = "copychat", specifier = ">=0.5.2" },
 
257
  { name = "ipython", specifier = ">=8.12.3" },
258
  { name = "pdbpp", specifier = ">=0.10.3" },
259
  { name = "pre-commit" },
 
169
  { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190 },
170
  ]
171
 
172
+ [[package]]
173
+ name = "dirty-equals"
174
+ version = "0.9.0"
175
+ source = { registry = "https://pypi.org/simple" }
176
+ sdist = { url = "https://files.pythonhosted.org/packages/b0/99/133892f401ced5a27e641a473c547d5fbdb39af8f85dac8a9d633ea3e7a7/dirty_equals-0.9.0.tar.gz", hash = "sha256:17f515970b04ed7900b733c95fd8091f4f85e52f1fb5f268757f25c858eb1f7b", size = 50412 }
177
+ wheels = [
178
+ { url = "https://files.pythonhosted.org/packages/77/0c/03cc99bf3b6328604b10829de3460f2b2ad3373200c45665c38508e550c6/dirty_equals-0.9.0-py3-none-any.whl", hash = "sha256:ff4d027f5cfa1b69573af00f7ba9043ea652dbdce3fe5cbe828e478c7346db9c", size = 28226 },
179
+ ]
180
+
181
  [[package]]
182
  name = "distlib"
183
  version = "0.3.9"
 
229
 
230
  [[package]]
231
  name = "fastmcp"
232
+ version = "0.4.2.dev28+g728aeec.d20250408"
233
  source = { editable = "." }
234
  dependencies = [
235
  { name = "mcp" },
 
241
  [package.dev-dependencies]
242
  dev = [
243
  { name = "copychat" },
244
+ { name = "dirty-equals" },
245
  { name = "ipython" },
246
  { name = "pdbpp" },
247
  { name = "pre-commit" },
 
264
  [package.metadata.requires-dev]
265
  dev = [
266
  { name = "copychat", specifier = ">=0.5.2" },
267
+ { name = "dirty-equals", specifier = ">=0.9.0" },
268
  { name = "ipython", specifier = ">=8.12.3" },
269
  { name = "pdbpp", specifier = ">=0.10.3" },
270
  { name = "pre-commit" },