Spaces:
Running
Running
Merge pull request #105 from jlowin/proxy
Browse files- pyproject.toml +4 -0
- src/fastmcp/clients/base.py +9 -3
- src/fastmcp/prompts/prompt_manager.py +7 -4
- src/fastmcp/resources/resource_manager.py +24 -12
- src/fastmcp/server/proxy.py +212 -0
- src/fastmcp/server/server.py +40 -14
- src/fastmcp/tools/tool_manager.py +8 -5
- tests/clients/test_fastmcp_client.py +5 -13
- tests/prompts/test_prompt_manager.py +4 -4
- tests/resources/test_resource_manager.py +3 -3
- tests/server/test_proxy.py +179 -0
- tests/tools/test_tool_manager.py +4 -4
- 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/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(
|
|
|
|
|
|
|
| 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}
|
| 22 |
-
|
|
|
|
| 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}
|
| 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(
|
|
|
|
|
|
|
| 14 |
"""Import resources from another resource manager.
|
| 15 |
|
| 16 |
-
Resources are imported with a prefixed URI
|
| 17 |
-
URI "data://users" and you import it with prefix "app", the
|
| 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}
|
| 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(
|
|
|
|
|
|
|
| 35 |
"""Import resource templates from another resource manager.
|
| 36 |
|
| 37 |
-
Templates are imported with a prefixed URI template
|
| 38 |
-
URI template "data://users/{id}" and you import
|
| 39 |
-
imported template will have URI template
|
|
|
|
| 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 =
|
|
|
|
|
|
|
| 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 |
-
|
| 67 |
-
|
| 68 |
-
# Import resources from the mounted app
|
| 69 |
-
self._resource_manager.import_resources(app._resource_manager, prefix)
|
| 70 |
|
| 71 |
-
# Import
|
| 72 |
-
|
|
|
|
|
|
|
| 73 |
|
| 74 |
-
# Import prompts
|
| 75 |
-
|
|
|
|
| 76 |
|
| 77 |
logger.info(f"Mounted app with prefix '{prefix}'")
|
| 78 |
-
logger.debug(f"Imported tools with prefix '{
|
| 79 |
-
logger.debug(f"Imported resources with prefix '{
|
| 80 |
-
logger.debug(f"Imported templates with prefix '{
|
| 81 |
-
logger.debug(f"Imported prompts with 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(
|
|
|
|
|
|
|
| 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
|
| 22 |
-
be in the format "{prefix}
|
| 23 |
-
|
|
|
|
| 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}
|
| 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
|
| 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/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.
|
| 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" },
|