Spaces:
Running
Running
Jeremiah Lowin commited on
Commit ·
7826473
1
Parent(s): 6da0e6b
Add proxy server
Browse files- pyproject.toml +4 -0
- src/fastmcp/clients/base.py +9 -3
- src/fastmcp/server/proxy.py +212 -0
- src/fastmcp/server/server.py +23 -1
- tests/clients/test_fastmcp_client.py +5 -13
- tests/server/test_proxy.py +179 -0
- uv.lock +12 -1
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/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
|
@@ -10,7 +10,9 @@ from fastmcp.tools.tool_manager import ToolManager
|
|
| 10 |
from fastmcp.utilities.logging import get_logger
|
| 11 |
|
| 12 |
if TYPE_CHECKING:
|
| 13 |
-
|
|
|
|
|
|
|
| 14 |
|
| 15 |
logger = get_logger(__name__)
|
| 16 |
|
|
@@ -83,3 +85,23 @@ class FastMCP(mcp.server.fastmcp.FastMCP):
|
|
| 83 |
logger.debug(f"Imported resources with prefix '{resource_prefix}'")
|
| 84 |
logger.debug(f"Imported templates with prefix '{resource_prefix}'")
|
| 85 |
logger.debug(f"Imported prompts with prefix '{prompt_prefix}'")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
|
|
| 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)
|
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
|
| 28 |
@server.tool()
|
| 29 |
-
def
|
| 30 |
-
"""
|
| 31 |
-
|
| 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 |
-
|
| 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/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!"
|
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.
|
| 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" },
|