Merge pull request #105 from jlowin/proxy

Add MCP proxy server
This commit is contained in:
Jeremiah Lowin 2025-04-08 13:33:38 -04:00 committed by GitHub
commit 14dc9f225d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 511 additions and 63 deletions

View file

@ -32,11 +32,15 @@ dev = [
"copychat>=0.5.2",
"ipython>=8.12.3",
"pdbpp>=0.10.3",
"dirty-equals>=0.9.0",
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
filterwarnings = [
"ignore:Accessing the 'model_fields' attribute on the instance is deprecated:DeprecationWarning",
]
[tool.hatch.version]
source = "vcs"

View file

@ -165,16 +165,22 @@ class BaseClient(abc.ABC):
"""Send a resources/listResourceTemplates request."""
return await self.session.list_resource_templates()
async def read_resource(self, uri: AnyUrl) -> mcp.types.ReadResourceResult:
async def read_resource(self, uri: AnyUrl | str) -> mcp.types.ReadResourceResult:
"""Send a resources/read request."""
if isinstance(uri, str):
uri = AnyUrl(uri)
return await self.session.read_resource(uri)
async def subscribe_resource(self, uri: AnyUrl) -> None:
async def subscribe_resource(self, uri: AnyUrl | str) -> None:
"""Send a resources/subscribe request."""
if isinstance(uri, str):
uri = AnyUrl(uri)
await self.session.subscribe_resource(uri)
async def unsubscribe_resource(self, uri: AnyUrl) -> None:
async def unsubscribe_resource(self, uri: AnyUrl | str) -> None:
"""Send a resources/unsubscribe request."""
if isinstance(uri, str):
uri = AnyUrl(uri)
await self.session.unsubscribe_resource(uri)
async def list_prompts(self) -> mcp.types.ListPromptsResult:

View file

@ -11,20 +11,23 @@ class PromptManager(BasePromptManager):
Adds ability to import prompts from other managers with prefixed names.
"""
def import_prompts(self, manager: "PromptManager", prefix: str) -> None:
def import_prompts(
self, manager: "PromptManager", prefix: str | None = None
) -> None:
"""
Import all prompts from another PromptManager with prefixed names.
Args:
manager: Another PromptManager instance to import prompts from
prefix: Prefix to add to prompt names. The resulting prompt name will
be in the format "{prefix}/{original_name}"
For example, with prefix "weather" and prompt "forecast_prompt",
be in the format "{prefix}{original_name}" if prefix is provided,
otherwise the original name is used.
For example, with prefix "weather/" and prompt "forecast_prompt",
the imported prompt would be available as "weather/forecast_prompt"
"""
for name, prompt in manager._prompts.items():
# Create prefixed name - we keep the original name in the Prompt object
prefixed_name = f"{prefix}/{name}"
prefixed_name = f"{prefix}{name}" if prefix else name
# Log the import
logger.debug(f"Importing prompt with name {name} as {prefixed_name}")

View file

@ -10,20 +10,25 @@ logger = logging.getLogger(__name__)
class ResourceManager(BaseResourceManager):
"""ResourceManager that adds methods to import resources from other managers."""
def import_resources(self, manager: "ResourceManager", prefix: str) -> None:
def import_resources(
self, manager: "ResourceManager", prefix: str | None = None
) -> None:
"""Import resources from another resource manager.
Resources are imported with a prefixed URI. For example, if a resource has
URI "data://users" and you import it with prefix "app", the imported resource
will have URI "app+data://users".
Resources are imported with a prefixed URI if a prefix is provided. For example,
if a resource has URI "data://users" and you import it with prefix "app+", the
imported resource will have URI "app+data://users". If no prefix is provided,
the original URI is used.
Args:
manager: The ResourceManager to import from
prefix: A prefix to apply to the resource URIs
prefix: A prefix to apply to the resource URIs, including the delimiter.
For example, "app+" would result in URIs like "app+data://users".
If None, the original URI is used.
"""
for uri, resource in manager._resources.items():
# Create prefixed URI and copy the resource with the new URI
prefixed_uri = f"{prefix}+{uri}"
prefixed_uri = f"{prefix}{uri}" if prefix else uri
# Log the import
logger.debug(f"Importing resource with URI {uri} as {prefixed_uri}")
@ -31,20 +36,27 @@ class ResourceManager(BaseResourceManager):
# Store directly in resources dictionary
self._resources[prefixed_uri] = resource
def import_templates(self, manager: "ResourceManager", prefix: str) -> None:
def import_templates(
self, manager: "ResourceManager", prefix: str | None = None
) -> None:
"""Import resource templates from another resource manager.
Templates are imported with a prefixed URI template. For example, if a template has
URI template "data://users/{id}" and you import it with prefix "app", the
imported template will have URI template "app+data://users/{id}".
Templates are imported with a prefixed URI template if a prefix is provided.
For example, if a template has URI template "data://users/{id}" and you import
it with prefix "app+", the imported template will have URI template
"app+data://users/{id}". If no prefix is provided, the original URI template is used.
Args:
manager: The ResourceManager to import templates from
prefix: A prefix to apply to the template URIs
prefix: A prefix to apply to the template URIs, including the delimiter.
For example, "app+" would result in URI templates like "app+data://users/{id}".
If None, the original URI template is used.
"""
for uri_template, template in manager._templates.items():
# Create prefixed URI template and copy the template with the new URI template
prefixed_uri_template = f"{prefix}+{uri_template}"
prefixed_uri_template = (
f"{prefix}{uri_template}" if prefix else uri_template
)
# Log the import
logger.debug(

212
src/fastmcp/server/proxy.py Normal file
View file

@ -0,0 +1,212 @@
from typing import Any, cast
import mcp.types
from mcp.server.fastmcp.prompts import Prompt
from mcp.server.fastmcp.resources import Resource, ResourceTemplate
from mcp.server.fastmcp.tools.base import Tool
from mcp.server.fastmcp.utilities.func_metadata import func_metadata
from mcp.types import BlobResourceContents, PromptMessage, TextResourceContents
from fastmcp.clients.base import BaseClient
from fastmcp.server.context import Context
from fastmcp.server.server import FastMCP
from fastmcp.utilities.logging import get_logger
logger = get_logger(__name__)
def _proxy_passthrough():
pass
class ProxyTool(Tool):
def __init__(self, client: "BaseClient", **kwargs):
super().__init__(**kwargs)
self._client = client
@classmethod
async def from_client(
cls, client: "BaseClient", tool: mcp.types.Tool
) -> "ProxyTool":
return cls(
client=client,
name=tool.name,
description=tool.description,
parameters=tool.inputSchema,
fn=_proxy_passthrough,
fn_metadata=func_metadata(_proxy_passthrough),
is_async=True,
)
async def run(
self, arguments: dict[str, Any], context: Context | None = None
) -> Any:
async with self._client:
result = await self._client.call_tool(self.name, arguments)
if result.isError:
raise ValueError(cast(mcp.types.TextContent, result.content[0]).text)
return result.content[0]
class ProxyResource(Resource):
def __init__(
self, client: "BaseClient", *, _value: str | bytes | None = None, **kwargs
):
super().__init__(**kwargs)
self._client = client
self._value = _value
@classmethod
async def from_client(
cls, client: "BaseClient", resource: mcp.types.Resource
) -> "ProxyResource":
return cls(
client=client,
uri=resource.uri,
name=resource.name,
description=resource.description,
mime_type=resource.mimeType,
)
async def read(self) -> str | bytes:
if self._value is not None:
return self._value
async with self._client:
result = await self._client.read_resource(self.uri)
if isinstance(result.contents[0], TextResourceContents):
return result.contents[0].text
elif isinstance(result.contents[0], BlobResourceContents):
return result.contents[0].blob
else:
raise ValueError(f"Unsupported content type: {type(result.contents[0])}")
class ProxyTemplate(ResourceTemplate):
def __init__(self, client: "BaseClient", **kwargs):
super().__init__(**kwargs)
self._client = client
@classmethod
async def from_client(
cls, client: "BaseClient", template: mcp.types.ResourceTemplate
) -> "ProxyTemplate":
return cls(
client=client,
uri_template=template.uriTemplate,
name=template.name,
description=template.description,
fn=_proxy_passthrough,
parameters={},
)
async def create_resource(self, uri: str, params: dict[str, Any]) -> ProxyResource:
async with self._client:
result = await self._client.read_resource(uri)
if isinstance(result.contents[0], TextResourceContents):
value = result.contents[0].text
elif isinstance(result.contents[0], BlobResourceContents):
value = result.contents[0].blob
else:
raise ValueError(f"Unsupported content type: {type(result.contents[0])}")
return ProxyResource(
client=self._client,
uri=uri,
name=self.name,
description=self.description,
mime_type=result.contents[0].mimeType,
contents=result.contents,
_value=value,
)
class ProxyPrompt(Prompt):
def __init__(self, client: "BaseClient", **kwargs):
super().__init__(**kwargs)
self._client = client
@classmethod
async def from_client(
cls, client: "BaseClient", prompt: mcp.types.Prompt
) -> "ProxyPrompt":
return cls(
client=client,
name=prompt.name,
description=prompt.description,
arguments=[a.model_dump() for a in prompt.arguments or []],
fn=_proxy_passthrough,
)
async def render(self, arguments: dict[str, Any]) -> list[PromptMessage]:
async with self._client:
result = await self._client.get_prompt(self.name, arguments)
return result.messages
class FastMCPProxy(FastMCP):
def __init__(self, _async_constructor: bool, **kwargs):
if not _async_constructor:
raise ValueError(
"FastMCPProxy() was initialied unexpectedly. Please use a constructor like `FastMCPProxy.from_client()` instead."
)
super().__init__(**kwargs)
@classmethod
async def from_client(
cls, client: "BaseClient", name: str | None = None, **settings: Any
) -> "FastMCPProxy":
"""Create a FastMCP proxy server from a client.
This method creates a new FastMCP server instance that proxies requests to the provided client.
It discovers the client's tools, resources, prompts, and templates, and creates corresponding
components in the server that forward requests to the client.
Args:
client: The client to proxy requests to
name: Optional name for the new FastMCP server (defaults to client name if available)
**settings: Additional settings for the FastMCP server
Returns:
A FastMCP server that proxies requests to the client
"""
server = cls(name=name, **settings, _async_constructor=True)
async with client:
# Register proxies for client tools
tools_result = await client.list_tools()
for tool in tools_result.tools:
tool_proxy = await ProxyTool.from_client(client, tool)
server._tool_manager._tools[tool_proxy.name] = tool_proxy
logger.debug(f"Created proxy for tool: {tool_proxy.name}")
# Register proxies for client resources
resources_result = await client.list_resources()
for resource in resources_result.resources:
resource_proxy = await ProxyResource.from_client(client, resource)
server._resource_manager._resources[str(resource_proxy.uri)] = (
resource_proxy
)
logger.debug(f"Created proxy for resource: {resource_proxy.uri}")
# Register proxies for client resource templates
templates_result = await client.list_resource_templates()
for template in templates_result.resourceTemplates:
template_proxy = await ProxyTemplate.from_client(client, template)
server._resource_manager._templates[template_proxy.uri_template] = (
template_proxy
)
logger.debug(
f"Created proxy for template: {template_proxy.uri_template}"
)
# Register proxies for client prompts
prompts_result = await client.list_prompts()
for prompt in prompts_result.prompts:
prompt_proxy = await ProxyPrompt.from_client(client, prompt)
server._prompt_manager._prompts[prompt_proxy.name] = prompt_proxy
logger.debug(f"Created proxy for prompt: {prompt_proxy.name}")
logger.info(f"Created server '{server.name}' proxying to client: {client}")
return server

View file

@ -1,4 +1,4 @@
from typing import Any, Dict
from typing import TYPE_CHECKING, Any, Dict
import mcp.server.fastmcp
import mcp.types
@ -9,6 +9,11 @@ from fastmcp.server.context import Context
from fastmcp.tools.tool_manager import ToolManager
from fastmcp.utilities.logging import get_logger
if TYPE_CHECKING:
from fastmcp.clients.base import BaseClient
from .proxy import FastMCPProxy
logger = get_logger(__name__)
@ -62,20 +67,41 @@ class FastMCP(mcp.server.fastmcp.FastMCP):
# Mount the app in the list of mounted apps
self._mounted_apps[prefix] = app
# Import tools from the mounted app
self._tool_manager.import_tools(app._tool_manager, prefix)
# Import tools from the mounted app with / delimiter
tool_prefix = f"{prefix}/"
self._tool_manager.import_tools(app._tool_manager, tool_prefix)
# Import resources from the mounted app
self._resource_manager.import_resources(app._resource_manager, prefix)
# Import resources and templates from the mounted app with + delimiter
resource_prefix = f"{prefix}+"
self._resource_manager.import_resources(app._resource_manager, resource_prefix)
self._resource_manager.import_templates(app._resource_manager, resource_prefix)
# Import resource templates
self._resource_manager.import_templates(app._resource_manager, prefix)
# Import prompts
self._prompt_manager.import_prompts(app._prompt_manager, prefix)
# Import prompts with / delimiter
prompt_prefix = f"{prefix}/"
self._prompt_manager.import_prompts(app._prompt_manager, prompt_prefix)
logger.info(f"Mounted app with prefix '{prefix}'")
logger.debug(f"Imported tools with prefix '{prefix}/'")
logger.debug(f"Imported resources with prefix '{prefix}+'")
logger.debug(f"Imported templates with prefix '{prefix}+'")
logger.debug(f"Imported prompts with prefix '{prefix}/'")
logger.debug(f"Imported tools with prefix '{tool_prefix}'")
logger.debug(f"Imported resources with prefix '{resource_prefix}'")
logger.debug(f"Imported templates with prefix '{resource_prefix}'")
logger.debug(f"Imported prompts with prefix '{prompt_prefix}'")
@classmethod
async def as_proxy(cls, client: "BaseClient", **settings: Any) -> "FastMCPProxy":
"""
Create a FastMCP proxy server from a client.
This method creates a new FastMCP server instance that proxies requests to the provided client.
It discovers the client's tools, resources, prompts, and templates, and creates corresponding
components in the server that forward requests to the client.
Args:
client: The client to proxy requests to
**settings: Additional settings for the FastMCP server
Returns:
A FastMCP server that proxies requests to the client
"""
from .proxy import FastMCPProxy
return await FastMCPProxy.from_client(client=client, **settings)

View file

@ -12,19 +12,22 @@ class ToolManager(mcp.server.fastmcp.tools.ToolManager):
Adds ability to import tools from other managers with prefixed names.
"""
def import_tools(self, tool_manager: "ToolManager", prefix: str) -> None:
def import_tools(
self, tool_manager: "ToolManager", prefix: str | None = None
) -> None:
"""
Import all tools from another ToolManager with prefixed names.
Args:
tool_manager: Another ToolManager instance to import tools from
prefix: Prefix to add to tool names. The resulting tool name will
be in the format "{prefix}/{original_name}"
For example, with prefix "weather" and tool "forecast",
prefix: Prefix to add to tool names, including the delimiter.
The resulting tool name will be in the format "{prefix}{original_name}"
if prefix is provided, otherwise the original name is used.
For example, with prefix "weather/" and tool "forecast",
the imported tool would be available as "weather/forecast"
"""
for name, tool in tool_manager._tools.items():
prefixed_name = f"{prefix}/{name}"
prefixed_name = f"{prefix}{name}" if prefix else name
# Create a shallow copy of the tool with the prefixed name
copied_tool = Tool.from_function(

View file

@ -7,12 +7,6 @@ from fastmcp.clients import FastMCPClient
from fastmcp.server.server import FastMCP
class _TestException(Exception):
"""Test exception for testing raise_exceptions behavior."""
pass
@pytest.fixture
def fastmcp_server():
"""Fixture that creates a FastMCP server with tools, resources, and prompts."""
@ -24,11 +18,11 @@ def fastmcp_server():
"""Greet someone by name."""
return f"Hello, {name}!"
# Add a tool that raises an exception
# Add a second tool
@server.tool()
def error_tool() -> str:
"""A tool that always raises an exception."""
raise _TestException("Deliberate test exception")
def add(a: int, b: int) -> int:
"""Add two numbers together."""
return a + b
# Add a resource
@server.resource(uri="data://users")
@ -57,9 +51,7 @@ async def test_list_tools(fastmcp_server):
# Check that our tools are available
assert len(result.tools) == 2
tool_names = [tool.name for tool in result.tools]
assert "greet" in tool_names
assert "error_tool" in tool_names
assert set(tool.name for tool in result.tools) == {"greet", "add"}
async def test_call_tool(fastmcp_server):

View file

@ -44,7 +44,7 @@ def test_import_prompts():
target_manager = PromptManager()
# Import prompts from source to target
prefix = "nlp"
prefix = "nlp/"
target_manager.import_prompts(source_manager, prefix)
# Verify prompts were imported with prefixes
@ -109,7 +109,7 @@ def test_import_prompts_with_duplicates():
target_manager._prompts["common"] = target_prompt
# Import prompts with prefix
prefix = "external"
prefix = "external/"
target_manager.import_prompts(source_manager, prefix)
# Verify both prompts exist in target manager
@ -146,10 +146,10 @@ def test_import_prompts_with_nested_prefixes():
first_manager._prompts["analyze"] = original_prompt
# Import to second manager with prefix
second_manager.import_prompts(first_manager, "text")
second_manager.import_prompts(first_manager, "text/")
# Import from second to third with another prefix
third_manager.import_prompts(second_manager, "ai")
third_manager.import_prompts(second_manager, "ai/")
# Verify the nested prefixing
assert "text/analyze" in second_manager._prompts

View file

@ -39,7 +39,7 @@ def test_import_resources():
target_manager = ResourceManager()
# Import resources from source to target
prefix = "data"
prefix = "data+"
target_manager.import_resources(source_manager, prefix)
# Verify resources were imported with prefixes
@ -126,7 +126,7 @@ def test_import_templates():
target_manager = ResourceManager()
# Import templates from source to target
prefix = "shop"
prefix = "shop+"
target_manager.import_templates(source_manager, prefix)
# Verify templates were imported with prefixes
@ -212,7 +212,7 @@ def test_import_multiple_resource_types():
target_manager = ResourceManager()
# Import both resources and templates
prefix = "test"
prefix = "test+"
target_manager.import_resources(source_manager, prefix)
target_manager.import_templates(source_manager, prefix)

179
tests/server/test_proxy.py Normal file
View file

@ -0,0 +1,179 @@
import json
from typing import Any
import pytest
from dirty_equals import Contains
from fastmcp import FastMCP
from fastmcp.clients.fastmcp_client import FastMCPClient
from fastmcp.server.proxy import FastMCPProxy
USERS = [
{"id": "1", "name": "Alice", "active": True},
{"id": "2", "name": "Bob", "active": True},
{"id": "3", "name": "Charlie", "active": False},
]
@pytest.fixture
def fastmcp_server():
server = FastMCP("TestServer")
# --- Tools ---
@server.tool()
def greet(name: str) -> str:
"""Greet someone by name."""
return f"Hello, {name}!"
@server.tool()
def add(a: int, b: int) -> int:
"""Add two numbers together."""
return a + b
@server.tool()
def error_tool():
"""This tool always raises an error."""
raise ValueError("This is a test error")
# --- Resources ---
@server.resource(uri="resource://wave")
def wave() -> str:
return "👋"
@server.resource(uri="data://users")
async def get_users() -> list[dict[str, Any]]:
return USERS
@server.resource(uri="data://user/{user_id}")
async def get_user(user_id: str) -> dict[str, Any] | None:
return next((user for user in USERS if user["id"] == user_id), None)
# --- Prompts ---
@server.prompt()
def welcome(name: str) -> str:
return f"Welcome to FastMCP, {name}!"
return server
@pytest.fixture
async def proxy_server(fastmcp_server):
"""Fixture that creates a FastMCP proxy server."""
return await FastMCP.as_proxy(FastMCPClient(fastmcp_server))
async def test_create_proxy(fastmcp_server):
"""Test that the proxy server properly forwards requests to the original server."""
# Create a client
client = FastMCPClient(fastmcp_server)
server = await FastMCPProxy.from_client(client)
assert isinstance(server, FastMCP)
assert server.name == "FastMCP"
class TestTools:
async def test_list_tools(self, proxy_server):
tools = await proxy_server.list_tools()
assert [t.name for t in tools] == Contains("greet", "add", "error_tool")
async def test_list_tools_same_as_original(self, fastmcp_server, proxy_server):
assert await proxy_server.list_tools() == await fastmcp_server.list_tools()
async def test_call_tool_result_same_as_original(
self, fastmcp_server: FastMCP, proxy_server: FastMCPProxy
):
result = await fastmcp_server.call_tool("greet", {"name": "Alice"})
proxy_result = await proxy_server.call_tool("greet", {"name": "Alice"})
assert result == proxy_result
async def test_call_tool_calls_tool(self, proxy_server):
proxy_result = await proxy_server.call_tool("add", {"a": 1, "b": 2})
assert proxy_result[0].text == "3"
async def test_error_tool_raises_error(self, proxy_server):
with pytest.raises(ValueError, match="This is a test error"):
await proxy_server.call_tool("error_tool", {})
class TestResources:
async def test_list_resources(self, proxy_server):
resources = await proxy_server.list_resources()
assert [r.name for r in resources] == Contains(
"data://users", "resource://wave"
)
async def test_list_resources_same_as_original(self, fastmcp_server, proxy_server):
assert (
await proxy_server.list_resources() == await fastmcp_server.list_resources()
)
async def test_read_resource(self, proxy_server: FastMCPProxy):
result = await proxy_server.read_resource("resource://wave")
assert result[0].content == "👋" # type: ignore
async def test_read_resource_same_as_original(self, fastmcp_server, proxy_server):
result = await fastmcp_server.read_resource("resource://wave")
proxy_result = await proxy_server.read_resource("resource://wave")
assert proxy_result == result
async def test_read_json_resource(self, proxy_server: FastMCPProxy):
result = await proxy_server.read_resource("data://users")
assert json.loads(result[0].content) == USERS # type: ignore
async def test_read_resource_returns_none_if_not_found(self, proxy_server):
with pytest.raises(
ValueError, match="Unknown resource: resource://nonexistent"
):
await proxy_server.read_resource("resource://nonexistent")
class TestResourceTemplates:
async def test_list_resource_templates(self, proxy_server):
templates = await proxy_server.list_resource_templates()
assert [t.name for t in templates] == Contains("get_user")
async def test_list_resource_templates_same_as_original(
self, fastmcp_server, proxy_server
):
result = await fastmcp_server.list_resource_templates()
proxy_result = await proxy_server.list_resource_templates()
assert proxy_result == result
@pytest.mark.parametrize("id", [1, 2, 3])
async def test_read_resource_template(self, proxy_server: FastMCPProxy, id: int):
result = await proxy_server.read_resource(f"data://user/{id}")
assert json.loads(result[0].content) == USERS[id - 1] # type: ignore
async def test_read_resource_template_same_as_original(
self, fastmcp_server, proxy_server
):
result = await fastmcp_server.read_resource("data://user/1")
proxy_result = await proxy_server.read_resource("data://user/1")
assert proxy_result == result
class TestPrompts:
async def test_list_prompts(self, proxy_server):
prompts = await proxy_server.list_prompts()
assert [p.name for p in prompts] == Contains("welcome")
async def test_list_prompts_same_as_original(self, fastmcp_server, proxy_server):
assert await proxy_server.list_prompts() == await fastmcp_server.list_prompts()
async def test_render_prompt_same_as_original(
self, fastmcp_server: FastMCP, proxy_server
):
result = await fastmcp_server.get_prompt("welcome", {"name": "Alice"})
proxy_result = await proxy_server.get_prompt("welcome", {"name": "Alice"})
assert proxy_result == result
async def test_render_prompt_calls_prompt(self, proxy_server):
result = await proxy_server.get_prompt("welcome", {"name": "Alice"})
assert result.messages[0].content.text == "Welcome to FastMCP, Alice!"

View file

@ -23,7 +23,7 @@ def test_import_tools():
target_manager = ToolManager()
# Import tools from source to target
prefix = "source"
prefix = "source/"
target_manager.import_tools(source_manager, prefix)
# Verify tools were imported with prefixes
@ -65,7 +65,7 @@ def test_tool_duplicate_behavior():
) # Pre-create with the prefixed name
# Import tools from source to target
target_manager.import_tools(source_manager, "source")
target_manager.import_tools(source_manager, "source/")
# The original tool in the target manager is replaced by the imported one
assert target_manager._tools["source/common_tool"].fn.__name__ == source_fn.__name__
@ -89,8 +89,8 @@ def test_import_tools_with_multiple_prefixes():
# Create target manager and import from both sources
main_manager = ToolManager()
main_manager.import_tools(weather_manager, "weather")
main_manager.import_tools(news_manager, "news")
main_manager.import_tools(weather_manager, "weather/")
main_manager.import_tools(news_manager, "news/")
# Verify tools were imported with correct prefixes
assert "weather/forecast" in main_manager._tools

13
uv.lock generated
View file

@ -169,6 +169,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190 },
]
[[package]]
name = "dirty-equals"
version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/99/133892f401ced5a27e641a473c547d5fbdb39af8f85dac8a9d633ea3e7a7/dirty_equals-0.9.0.tar.gz", hash = "sha256:17f515970b04ed7900b733c95fd8091f4f85e52f1fb5f268757f25c858eb1f7b", size = 50412 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/77/0c/03cc99bf3b6328604b10829de3460f2b2ad3373200c45665c38508e550c6/dirty_equals-0.9.0-py3-none-any.whl", hash = "sha256:ff4d027f5cfa1b69573af00f7ba9043ea652dbdce3fe5cbe828e478c7346db9c", size = 28226 },
]
[[package]]
name = "distlib"
version = "0.3.9"
@ -220,7 +229,7 @@ wheels = [
[[package]]
name = "fastmcp"
version = "0.4.2.dev21+g0b6e56c.d20250407"
version = "0.4.2.dev28+g728aeec.d20250408"
source = { editable = "." }
dependencies = [
{ name = "mcp" },
@ -232,6 +241,7 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "copychat" },
{ name = "dirty-equals" },
{ name = "ipython" },
{ name = "pdbpp" },
{ name = "pre-commit" },
@ -254,6 +264,7 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "copychat", specifier = ">=0.5.2" },
{ name = "dirty-equals", specifier = ">=0.9.0" },
{ name = "ipython", specifier = ">=8.12.3" },
{ name = "pdbpp", specifier = ">=0.10.3" },
{ name = "pre-commit" },