From d39f787537347a7c0cd216362a8c9b58c3cde35f Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 15 Apr 2025 13:01:36 -0400 Subject: [PATCH 01/12] rename mount -> import --- src/fastmcp/server/server.py | 74 +++++++++++-------- tests/client/test_client.py | 2 +- .../{test_mount.py => test_import_server.py} | 52 ++++++------- tests/server/test_openapi.py | 2 +- 4 files changed, 73 insertions(+), 57 deletions(-) rename tests/server/{test_mount.py => test_import_server.py} (90%) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index d1c04087d..55116159f 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -616,34 +616,47 @@ class FastMCP(Generic[LifespanResultT]): logger.error(f"Error getting prompt {name}: {e}") raise ValueError(str(e)) - def mount( + def import_server( self, prefix: str, - app: "FastMCP", + server: "FastMCP", tool_separator: str | None = None, resource_separator: str | None = None, prompt_separator: str | None = None, ) -> None: - """Mount another FastMCP application with a given prefix. + """ + Import the MCP objects from another FastMCP server into this one, + optionally with a given prefix. - When an application is mounted: - - The tools are imported with prefixed names using the tool_separator - Example: If app has a tool named "get_weather", it will be available as "weatherget_weather" - - The resources are imported with prefixed URIs using the resource_separator - Example: If app has a resource with URI "weather://forecast", it will be available as "weather+weather://forecast" - - The templates are imported with prefixed URI templates using the resource_separator - Example: If app has a template with URI "weather://location/{id}", it will be available as "weather+weather://location/{id}" - - The prompts are imported with prefixed names using the prompt_separator - Example: If app has a prompt named "weather_prompt", it will be available as "weather_weather_prompt" - - The mounted app's lifespan will be executed when the parent app's lifespan runs, - ensuring that any setup needed by the mounted app is performed + Note that when a server is *imported*, its objects are immediately + registered to the importing server. This is a one-time operation and + future changes to the imported server will not be reflected in the + importing server. Server-level configurations and lifespans are not imported. + + When an server is mounted: - The tools are imported with prefixed names + using the tool_separator + Example: If server has a tool named "get_weather", it will be + available as "weatherget_weather" + - The resources are imported with prefixed URIs using the + resource_separator Example: If server has a resource with URI + "weather://forecast", it will be available as + "weather+weather://forecast" + - The templates are imported with prefixed URI templates using the + resource_separator Example: If server has a template with URI + "weather://location/{id}", it will be available as + "weather+weather://location/{id}" + - The prompts are imported with prefixed names using the + prompt_separator Example: If server has a prompt named + "weather_prompt", it will be available as "weather_weather_prompt" + - The mounted server's lifespan will be executed when the parent + server's lifespan runs, ensuring that any setup needed by the mounted + server is performed Args: - prefix: The prefix to use for the mounted application - app: The FastMCP application to mount - tool_separator: Separator for tool names (defaults to "_") - resource_separator: Separator for resource URIs (defaults to "+") - prompt_separator: Separator for prompt names (defaults to "_") + prefix: The prefix to use for the mounted server server: The FastMCP + server to mount tool_separator: Separator for tool names (defaults + to "_") resource_separator: Separator for resource URIs (defaults to + "+") prompt_separator: Separator for prompt names (defaults to "_") """ if tool_separator is None: tool_separator = "_" @@ -652,23 +665,24 @@ class FastMCP(Generic[LifespanResultT]): if prompt_separator is None: prompt_separator = "_" - # Mount the app in the list of mounted apps - self._mounted_apps[prefix] = app - - # Import tools from the mounted app + # Import tools from the mounted server tool_prefix = f"{prefix}{tool_separator}" - self._tool_manager.import_tools(app._tool_manager, tool_prefix) + self._tool_manager.import_tools(server._tool_manager, tool_prefix) - # Import resources and templates from the mounted app + # Import resources and templates from the mounted server resource_prefix = f"{prefix}{resource_separator}" - self._resource_manager.import_resources(app._resource_manager, resource_prefix) - self._resource_manager.import_templates(app._resource_manager, resource_prefix) + self._resource_manager.import_resources( + server._resource_manager, resource_prefix + ) + self._resource_manager.import_templates( + server._resource_manager, resource_prefix + ) - # Import prompts from the mounted app + # Import prompts from the mounted server prompt_prefix = f"{prefix}{prompt_separator}" - self._prompt_manager.import_prompts(app._prompt_manager, prompt_prefix) + self._prompt_manager.import_prompts(server._prompt_manager, prompt_prefix) - logger.info(f"Mounted app with prefix '{prefix}'") + logger.info(f"Imported server {server.name} 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}'") diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 5121d95f5..160b32968 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -87,7 +87,7 @@ def mounted_resources_server(): return {"id": id, "source": "subapp"} # Mount the sub-app to the main server with a prefix - main_server.mount("sub", sub_app) + main_server.import_server("sub", sub_app) # Add a resource to the main server @main_server.resource(uri="main://data", description="Main resource") diff --git a/tests/server/test_mount.py b/tests/server/test_import_server.py similarity index 90% rename from tests/server/test_mount.py rename to tests/server/test_import_server.py index e66e65c1b..26e230c84 100644 --- a/tests/server/test_mount.py +++ b/tests/server/test_import_server.py @@ -2,6 +2,7 @@ import contextlib import json from urllib.parse import quote +import pytest from mcp.types import TextContent from fastmcp.server.server import FastMCP @@ -19,7 +20,7 @@ async def test_mount_basic_functionality(): return "This is from the sub app" # Mount the sub-app to the main app - main_app.mount("sub", sub_app) + main_app.import_server("sub", sub_app) # Verify the tool was imported with the prefix assert "sub_sub_tool" in main_app._tool_manager._tools @@ -49,8 +50,8 @@ async def test_mount_multiple_apps(): return "News headlines" # Mount both sub-apps to the main app - main_app.mount("weather", weather_app) - main_app.mount("news", news_app) + main_app.import_server("weather", weather_app) + main_app.import_server("news", news_app) # Verify tools were imported with the correct prefixes assert "weather_get_forecast" in main_app._tool_manager._tools @@ -74,11 +75,11 @@ async def test_mount_combines_tools(): return "Second app tool" # Mount first app - main_app.mount("api", first_app) + main_app.import_server("api", first_app) assert "api_first_tool" in main_app._tool_manager._tools # Mount second app to same prefix - main_app.mount("api", second_app) + main_app.import_server("api", second_app) # Verify second tool is there assert "api_second_tool" in main_app._tool_manager._tools @@ -99,7 +100,7 @@ async def test_mount_with_resources(): return ["user1", "user2"] # Mount the data app - main_app.mount("data", data_app) + main_app.import_server("data", data_app) # Verify the resource was imported with the prefix assert "data+data://users" in main_app._resource_manager._resources @@ -117,7 +118,7 @@ async def test_mount_with_resource_templates(): return {"id": user_id, "name": f"User {user_id}"} # Mount the user app - main_app.mount("api", user_app) + main_app.import_server("api", user_app) # Verify the template was imported with the prefix assert "api+users://{user_id}/profile" in main_app._resource_manager._templates @@ -135,7 +136,7 @@ async def test_mount_with_prompts(): return f"Hello, {name}!" # Mount the assistant app - main_app.mount("assistant", assistant_app) + main_app.import_server("assistant", assistant_app) # Verify the prompt was imported with the prefix assert "assistant_greeting" in main_app._prompt_manager._prompts @@ -158,8 +159,8 @@ async def test_mount_multiple_resource_templates(): return f"News for {category}" # Mount both apps - main_app.mount("data", weather_app) - main_app.mount("content", news_app) + main_app.import_server("data", weather_app) + main_app.import_server("content", news_app) # Verify templates were imported with correct prefixes assert "data+weather://{city}" in main_app._resource_manager._templates @@ -183,14 +184,15 @@ async def test_mount_multiple_prompts(): return f"Explaining SQL query:\n{query}" # Mount both apps - main_app.mount("python", python_app) - main_app.mount("sql", sql_app) + main_app.import_server("python", python_app) + main_app.import_server("sql", sql_app) # Verify prompts were imported with correct prefixes assert "python_review_python" in main_app._prompt_manager._prompts assert "sql_explain_sql" in main_app._prompt_manager._prompts +@pytest.mark.xfail(reason="Lifespans are not yet implemented") async def test_mount_lifespan(): """Test that the lifespan of a mounted app is properly handled.""" # Create apps @@ -209,8 +211,8 @@ async def test_mount_lifespan(): sub_app = FastMCP("SubApp", lifespan=lifespan) sub_app_2 = FastMCP("SubApp2", lifespan=lifespan) - main_app.mount("sub", sub_app) - main_app.mount("sub2", sub_app_2) + main_app.import_server("sub", sub_app) + main_app.import_server("sub2", sub_app_2) low_level_server = main_app._mcp_server async with contextlib.AsyncExitStack() as stack: @@ -243,7 +245,7 @@ async def test_tool_custom_name_preserved_when_mounted(): return f"Data for query: {query}" api_app.add_tool(fetch_data, name="get_data") - main_app.mount("api", api_app) + main_app.import_server("api", api_app) # Check that the tool is accessible by its prefixed name tool = main_app._tool_manager.get_tool("api_get_data") @@ -262,7 +264,7 @@ async def test_call_mounted_custom_named_tool(): return f"Data for query: {query}" api_app.add_tool(fetch_data, name="get_data") - main_app.mount("api", api_app) + main_app.import_server("api", api_app) context = main_app.get_context() result = await main_app._tool_manager.call_tool( @@ -280,7 +282,7 @@ async def test_first_level_mounting_with_custom_name(): return input * 2 provider_app.add_tool(calculate_value, name="compute") - service_app.mount("provider", provider_app) + service_app.import_server("provider", provider_app) # Tool is accessible in the service app with the first prefix tool = service_app._tool_manager.get_tool("provider_compute") @@ -298,8 +300,8 @@ async def test_nested_mounting_preserves_prefixes(): return input * 2 provider_app.add_tool(calculate_value, name="compute") - service_app.mount("provider", provider_app) - main_app.mount("service", service_app) + service_app.import_server("provider", provider_app) + main_app.import_server("service", service_app) # Tool is accessible in the main app with both prefixes tool = main_app._tool_manager.get_tool("service_provider_compute") @@ -316,8 +318,8 @@ async def test_call_nested_mounted_tool(): return input * 2 provider_app.add_tool(calculate_value, name="compute") - service_app.mount("provider", provider_app) - main_app.mount("service", service_app) + service_app.import_server("provider", provider_app) + main_app.import_server("service", service_app) result = await main_app._tool_manager.call_tool( "service_provider_compute", {"input": 21} @@ -341,7 +343,7 @@ async def test_mount_with_proxy_tools(): def get_data(query: str) -> str: return f"Data for query: {query}" - main_app.mount("api", await FastMCP.as_proxy(api_app)) + main_app.import_server("api", await FastMCP.as_proxy(api_app)) result = await main_app.call_tool("api_get_data", {"query": "test"}) assert isinstance(result[0], TextContent) @@ -363,7 +365,7 @@ async def test_mount_with_proxy_prompts(): def greeting(name: str) -> str: return f"Hello, {name} from API!" - main_app.mount("api", await FastMCP.as_proxy(api_app)) + main_app.import_server("api", await FastMCP.as_proxy(api_app)) result = await main_app.get_prompt("api_greeting", {"name": "World"}) assert len(result) > 0 @@ -390,7 +392,7 @@ async def test_mount_with_proxy_resources(): "base_url": "https://api.example.com", } - main_app.mount("api", await FastMCP.as_proxy(api_app)) + main_app.import_server("api", await FastMCP.as_proxy(api_app)) # Access the resource through the main app with the prefixed key resource = await main_app.read_resource("api+config://settings") @@ -416,7 +418,7 @@ async def test_mount_with_proxy_resource_templates(): def create_user(name: str, email: str): return {"name": name, "email": email} - main_app.mount("api", await FastMCP.as_proxy(api_app)) + main_app.import_server("api", await FastMCP.as_proxy(api_app)) # Instantiate the template through the main app with the prefixed key quoted_name = quote("John Doe", safe="") diff --git a/tests/server/test_openapi.py b/tests/server/test_openapi.py index d1c7c91b4..160c29d6c 100644 --- a/tests/server/test_openapi.py +++ b/tests/server/test_openapi.py @@ -694,7 +694,7 @@ class TestMountFastMCP: """Test mounting an OpenAPI server.""" mcp = FastMCP("MainApp") - mcp.mount("fastapi", fastmcp_openapi_server) + mcp.import_server("fastapi", fastmcp_openapi_server) # Check that resources are available with prefixed URIs resources = await mcp._mcp_list_resources() From 8d922d995afecc986178102654326e919b51aa31 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 15 Apr 2025 13:54:33 -0400 Subject: [PATCH 02/12] restore mcp tool generation to server --- src/fastmcp/server/server.py | 6 +--- src/fastmcp/tools/tool_manager.py | 6 +--- tests/server/test_proxy.py | 8 +++-- tests/server/test_server.py | 35 +++++++++++++++++--- tests/tools/test_tool_manager.py | 54 ------------------------------- 5 files changed, 38 insertions(+), 71 deletions(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 55116159f..e7b267375 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -188,10 +188,6 @@ class FastMCP(Generic[LifespanResultT]): """Get all registered tools, indexed by registered key.""" return self._tool_manager.get_tools() - def list_tools(self) -> list[Tool]: - """List all registered tools.""" - return self._tool_manager.list_tools() - async def _mcp_list_tools(self) -> list[MCPTool]: """ List all available tools, in the format expected by the low-level MCP @@ -199,7 +195,7 @@ class FastMCP(Generic[LifespanResultT]): See `list_tools` for a more ergonomic way to list tools. """ - return self._tool_manager.list_mcp_tools() + return [tool.to_mcp_tool(name=key) for key, tool in self.get_tools().items()] def get_context(self) -> "Context[ServerSession, LifespanResultT]": """ diff --git a/src/fastmcp/tools/tool_manager.py b/src/fastmcp/tools/tool_manager.py index f11d98e22..f82c9f5be 100644 --- a/src/fastmcp/tools/tool_manager.py +++ b/src/fastmcp/tools/tool_manager.py @@ -7,7 +7,7 @@ from mcp.shared.context import LifespanContextT from fastmcp.exceptions import ToolError from fastmcp.settings import DuplicateBehavior -from fastmcp.tools.tool import MCPTool, Tool +from fastmcp.tools.tool import Tool from fastmcp.utilities.logging import get_logger if TYPE_CHECKING: @@ -48,10 +48,6 @@ class ToolManager: """List all registered tools.""" return list(self.get_tools().values()) - def list_mcp_tools(self) -> list[MCPTool]: - """List all registered tools in the format expected by the low-level MCP server.""" - return [tool.to_mcp_tool(name=key) for key, tool in self._tools.items()] - def add_tool_from_fn( self, fn: Callable[..., Any], diff --git a/tests/server/test_proxy.py b/tests/server/test_proxy.py index 1c1e23cd4..75e9b7a1f 100644 --- a/tests/server/test_proxy.py +++ b/tests/server/test_proxy.py @@ -80,9 +80,11 @@ async def test_create_proxy(fastmcp_server): class TestTools: - async def test_list_tools(self, proxy_server): - tools = proxy_server.list_tools() - assert [t.name for t in tools] == Contains("greet", "add", "error_tool") + async def test_get_tools(self, proxy_server): + tools = proxy_server.get_tools() + assert "greet" in tools + assert "add" in tools + assert "error_tool" in tools async def test_list_tools_same_as_original(self, fastmcp_server, proxy_server): assert ( diff --git a/tests/server/test_server.py b/tests/server/test_server.py index 219c249f1..c5cef1fee 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -57,6 +57,34 @@ class TestCreateServer: assert "¡Hola, 世界! 👋" == content.text +class TestTools: + async def test_mcp_tool_name(self): + """Test MCPTool name for add_tool (key != tool.name).""" + + mcp = FastMCP() + + @mcp.tool() + def fn(x: int) -> int: + return x + 1 + + mcp_tools = await mcp._mcp_list_tools() + assert len(mcp_tools) == 1 + assert mcp_tools[0].name == "fn" + + async def test_mcp_tool_custom_name(self): + """Test MCPTool name for add_tool (key != tool.name).""" + + mcp = FastMCP() + + @mcp.tool(name="custom_name") + def fn(x: int) -> int: + return x + 1 + + mcp_tools = await mcp._mcp_list_tools() + assert len(mcp_tools) == 1 + assert mcp_tools[0].name == "custom_name" + + class TestToolDecorator: async def test_no_tools_before_decorator(self): mcp = FastMCP() @@ -216,9 +244,8 @@ class TestToolDecorator: mcp.add_tool(multiply, name="custom_multiply") # Check that the tool is registered with the custom name - tools = mcp.list_tools() - tool_names = [t.name for t in tools] - assert "custom_multiply" in tool_names + tools = mcp.get_tools() + assert "custom_multiply" in tools # Call the tool by its custom name result = await mcp.call_tool("custom_multiply", {"a": 5, "b": 3}) @@ -226,7 +253,7 @@ class TestToolDecorator: assert result[0].text == "15" # Original name should not be registered - assert "multiply" not in tool_names + assert "multiply" not in tools class TestResourceDecorator: diff --git a/tests/tools/test_tool_manager.py b/tests/tools/test_tool_manager.py index 5b8d79e7c..2c3639b56 100644 --- a/tests/tools/test_tool_manager.py +++ b/tests/tools/test_tool_manager.py @@ -612,35 +612,6 @@ class TestCustomToolNames: with pytest.raises(ToolError): await manager.call_tool("multiply", {"a": 5, "b": 3}) - def test_tool_to_mcp_tool(self): - """Test that to_mcp_tool uses the key, not the internal name.""" - - def some_function(x: int) -> int: - return x - - manager = ToolManager() - tool = Tool.from_function(some_function, name="api_function") - manager.add_tool(tool) - - mcp_tools = manager.list_mcp_tools() - assert len(mcp_tools) == 1 - assert mcp_tools[0].name == "api_function" - - def test_tool_to_mcp_tool_with_custom_key(self): - """Test that to_mcp_tool uses the key, not the internal name.""" - - def some_function(x: int) -> int: - return x - - manager = ToolManager() - tool = Tool.from_function(some_function, name="api_function") - manager.add_tool(tool, key="custom-key") - - # When listing tools for MCP, the key should be used - mcp_tools = manager.list_mcp_tools() - assert len(mcp_tools) == 1 - assert mcp_tools[0].name == "custom-key" - def test_import_tools_with_custom_names(self): """Test importing tools with custom names.""" @@ -688,28 +659,3 @@ class TestCustomToolNames: # But the function is different assert stored_tool.fn.__name__ == "replacement_fn" - - def test_mcp_tool_name_for_add_tool(self): - """Test MCPTool name for add_tool (key != tool.name).""" - - def fn(x: int) -> int: - return x + 1 - - tool = Tool.from_function(fn, name="my_tool") - manager = ToolManager() - manager.add_tool(tool, key="proxy_tool") - mcp_tools = manager.list_mcp_tools() - assert len(mcp_tools) == 1 - assert mcp_tools[0].name == "proxy_tool" - - def test_mcp_tool_name_for_add_tool_from_fn(self): - """Test MCPTool name for add_tool_from_fn (key == tool.name).""" - - def fn(x: int) -> int: - return x + 1 - - manager = ToolManager() - manager.add_tool_from_fn(fn, name="custom_fn") - mcp_tools = manager.list_mcp_tools() - assert len(mcp_tools) == 1 - assert mcp_tools[0].name == "custom_fn" From d27141a935f4cdb0811e239f2755901a48541432 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 15 Apr 2025 20:28:14 -0400 Subject: [PATCH 03/12] Refactor remote servers --- pyproject.toml | 1 + src/fastmcp/client/client.py | 31 +- src/fastmcp/client/transports.py | 30 +- src/fastmcp/exceptions.py | 8 + src/fastmcp/prompts/prompt.py | 2 +- src/fastmcp/prompts/prompt_manager.py | 38 +- src/fastmcp/resources/resource_manager.py | 82 +--- src/fastmcp/server/openapi.py | 26 +- src/fastmcp/server/proxy.py | 147 ++++--- src/fastmcp/server/server.py | 436 ++++++++++-------- src/fastmcp/settings.py | 3 + src/fastmcp/tools/tool.py | 65 ++- src/fastmcp/tools/tool_manager.py | 26 +- tests/client/test_client.py | 88 ---- tests/prompts/test_base.py | 11 - tests/prompts/test_prompt_manager.py | 147 +------ tests/resources/test_file_resources.py | 4 - tests/resources/test_function_resources.py | 7 - tests/resources/test_resource_manager.py | 415 +++--------------- tests/resources/test_resource_template.py | 6 - tests/resources/test_resources.py | 1 - tests/server/test_file_server.py | 9 +- tests/server/test_import_server.py | 224 ++++------ tests/server/test_lifespan.py | 2 - tests/server/test_openapi.py | 80 +++- tests/server/test_proxy.py | 98 +++-- tests/server/test_run_server.py | 2 +- tests/server/test_server.py | 486 ++++++++++++--------- tests/tools/test_tool_manager.py | 255 ++++------- tests/utilities/test_func_metadata.py | 3 - uv.lock | 4 +- 31 files changed, 1111 insertions(+), 1626 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f5b6c5fd3..a2a2297ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "websockets>=15.0.1", "fastapi>=0.115.12", "openapi-pydantic>=0.5.1", + "exceptiongroup>=1.2.2", ] requires-python = ">=3.10" readme = "README.md" diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index dbae1b965..c49472d2b 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -17,6 +17,7 @@ from fastmcp.client.roots import ( create_roots_callback, ) from fastmcp.client.sampling import SamplingHandler, create_sampling_callback +from fastmcp.exceptions import ClientError from fastmcp.server import FastMCP from .transports import ClientTransport, SessionKwargs, infer_transport @@ -24,10 +25,6 @@ from .transports import ClientTransport, SessionKwargs, infer_transport __all__ = ["Client", "RootsHandler", "RootsList"] -class ClientError(ValueError): - """Base class for errors raised by the client.""" - - class Client: """ MCP client that delegates connection management to a Transport instance. @@ -48,7 +45,7 @@ class Client: ): self.transport = infer_transport(transport) self._session: ClientSession | None = None - self._session_cm: AbstractAsyncContextManager[ClientSession] | None = None + self._session_cms: list[AbstractAsyncContextManager[ClientSession]] = [] self._session_kwargs: SessionKwargs = { "sampling_callback": None, @@ -89,24 +86,28 @@ class Client: async def __aenter__(self): if self.is_connected(): - raise RuntimeError("Client is already connected in an async context.") + # We're already connected, no need to add None to the session_cms list + return self + try: - self._session_cm = self.transport.connect_session(**self._session_kwargs) - self._session = await self._session_cm.__aenter__() + session_cm = self.transport.connect_session(**self._session_kwargs) + self._session_cms.append(session_cm) + self._session = await self._session_cms[-1].__aenter__() return self except Exception as e: # Ensure cleanup if __aenter__ fails partially self._session = None - self._session_cm = None + if self._session_cms: + self._session_cms.pop() raise ConnectionError( f"Failed to connect using {self.transport}: {e}" ) from e async def __aexit__(self, exc_type, exc_val, exc_tb): - if self._session_cm: - await self._session_cm.__aexit__(exc_type, exc_val, exc_tb) - self._session = None - self._session_cm = None + if self._session_cms: + await self._session_cms[-1].__aexit__(exc_type, exc_val, exc_tb) + self._session = None + self._session_cms.pop() # --- MCP Client Methods --- async def ping(self) -> None: @@ -168,10 +169,10 @@ class Client: async def get_prompt( self, name: str, arguments: dict[str, str] | None = None - ) -> mcp.types.GetPromptResult: + ) -> list[mcp.types.PromptMessage]: """Send a prompts/get request.""" result = await self.session.get_prompt(name, arguments) - return result + return result.messages async def complete( self, diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 27b885688..8817b134a 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -8,7 +8,8 @@ from typing import ( TypedDict, ) -from mcp import ClientSession, StdioServerParameters +from exceptiongroup import BaseExceptionGroup, catch +from mcp import ClientSession, McpError, StdioServerParameters from mcp.client.session import ( ListRootsFnT, LoggingFnT, @@ -22,6 +23,7 @@ from mcp.shared.memory import create_connected_server_and_client_session from pydantic import AnyUrl from typing_extensions import Unpack +from fastmcp.exceptions import ClientError from fastmcp.server import FastMCP as FastMCPServer @@ -382,12 +384,26 @@ class FastMCPTransport(ClientTransport): async def connect_session( self, **session_kwargs: Unpack[SessionKwargs] ) -> AsyncIterator[ClientSession]: - # create_connected_server_and_client_session manages the session lifecycle itself - async with create_connected_server_and_client_session( - server=self._fastmcp._mcp_server, - **session_kwargs, - ) as session: - yield session + def exception_handler(excgroup: BaseExceptionGroup): + for exc in excgroup.exceptions: + if isinstance(exc, BaseExceptionGroup): + exception_handler(exc) + raise exc + + def mcperror_handler(excgroup: BaseExceptionGroup): + for exc in excgroup.exceptions: + if isinstance(exc, BaseExceptionGroup): + mcperror_handler(exc) + raise ClientError(exc) + + # backport of 3.11's except* syntax + with catch({McpError: mcperror_handler, Exception: exception_handler}): + # create_connected_server_and_client_session manages the session lifecycle itself + async with create_connected_server_and_client_session( + server=self._fastmcp._mcp_server, + **session_kwargs, + ) as session: + yield session def __repr__(self) -> str: return f"" diff --git a/src/fastmcp/exceptions.py b/src/fastmcp/exceptions.py index 1314ef615..c105cce8e 100644 --- a/src/fastmcp/exceptions.py +++ b/src/fastmcp/exceptions.py @@ -23,3 +23,11 @@ class PromptError(FastMCPError): class InvalidSignature(Exception): """Invalid signature for use with FastMCP.""" + + +class ClientError(Exception): + """Error in client operations.""" + + +class NotFoundError(Exception): + """Object not found.""" diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py index 5733b3705..c6bcefc62 100644 --- a/src/fastmcp/prompts/prompt.py +++ b/src/fastmcp/prompts/prompt.py @@ -115,7 +115,7 @@ class Prompt(BaseModel): return cls( name=func_name, - description=description or fn.__doc__ or "", + description=description or fn.__doc__, arguments=arguments, fn=fn, tags=tags or set(), diff --git a/src/fastmcp/prompts/prompt_manager.py b/src/fastmcp/prompts/prompt_manager.py index ce423c731..f223d1b32 100644 --- a/src/fastmcp/prompts/prompt_manager.py +++ b/src/fastmcp/prompts/prompt_manager.py @@ -3,8 +3,8 @@ from collections.abc import Awaitable, Callable from typing import Any -from fastmcp.exceptions import PromptError -from fastmcp.prompts.prompt import MCPPrompt, Message, Prompt, PromptResult +from fastmcp.exceptions import NotFoundError +from fastmcp.prompts.prompt import Message, Prompt, PromptResult from fastmcp.settings import DuplicateBehavior from fastmcp.utilities.logging import get_logger @@ -37,16 +37,6 @@ class PromptManager: """Get all registered prompts, indexed by registered key.""" return self._prompts - def list_prompts(self) -> list[Prompt]: - """List all registered prompts.""" - return list(self.get_prompts().values()) - - def list_mcp_prompts(self) -> list[MCPPrompt]: - """List all registered prompts in the format expected by the low-level MCP server.""" - return [ - prompt.to_mcp_prompt(name=key) for key, prompt in self.get_prompts().items() - ] - def add_prompt_from_fn( self, fn: Callable[..., PromptResult | Awaitable[PromptResult]], @@ -84,28 +74,6 @@ class PromptManager: """Render a prompt by name with arguments.""" prompt = self.get_prompt(name) if not prompt: - raise PromptError(f"Unknown prompt: {name}") + raise NotFoundError(f"Unknown prompt: {name}") return await prompt.render(arguments) - - 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 key will - 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 key - key = f"{prefix}{name}" if prefix else name - - # Store the prompt with the prefixed key - self.add_prompt(prompt, key=key) - logger.debug(f'Imported prompt "{prompt.name}" as "{key}"') diff --git a/src/fastmcp/resources/resource_manager.py b/src/fastmcp/resources/resource_manager.py index 7f3256a76..4a83a2315 100644 --- a/src/fastmcp/resources/resource_manager.py +++ b/src/fastmcp/resources/resource_manager.py @@ -6,11 +6,10 @@ from typing import Any from pydantic import AnyUrl -from fastmcp.exceptions import ResourceError +from fastmcp.exceptions import NotFoundError from fastmcp.resources import FunctionResource -from fastmcp.resources.resource import MCPResource, Resource +from fastmcp.resources.resource import Resource from fastmcp.resources.template import ( - MCPResourceTemplate, ResourceTemplate, match_uri_template, ) @@ -207,7 +206,7 @@ class ResourceManager: """Get resource by URI, checking concrete resources first, then templates. Raises: - ResourceError: If no resource or template matching the URI is found. + NotFoundError: If no resource or template matching the URI is found. """ uri_str = str(uri) logger.debug("Getting resource", extra={"uri": uri_str}) @@ -225,85 +224,12 @@ class ResourceManager: except Exception as e: raise ValueError(f"Error creating resource from template: {e}") - raise ResourceError(f"Unknown resource: {uri_str}") + raise NotFoundError(f"Unknown resource: {uri_str}") def get_resources(self) -> dict[str, Resource]: """Get all registered resources, keyed by URI.""" return self._resources - def list_resources(self) -> list[Resource]: - """List all registered resources.""" - logger.debug("Listing resources", extra={"count": len(self._resources)}) - return list(self._resources.values()) - - def list_mcp_resources(self) -> list[MCPResource]: - """List all registered resources in the format expected by the low-level MCP server.""" - - return [ - resource.to_mcp_resource(uri=key) - for key, resource in self._resources.items() - ] - - def list_mcp_resource_templates(self) -> list[MCPResourceTemplate]: - """List all registered resource templates in the format expected by the low-level MCP server.""" - return [ - template.to_mcp_template(uriTemplate=key) - for key, template in self._templates.items() - ] - def get_templates(self) -> dict[str, ResourceTemplate]: """Get all registered templates, keyed by URI template.""" return self._templates - - def list_templates(self) -> list[ResourceTemplate]: - """List all registered templates.""" - logger.debug("Listing templates", extra={"count": len(self._templates)}) - return list(self._templates.values()) - - def import_resources( - self, manager: "ResourceManager", prefix: str | None = None - ) -> None: - """Import resources from another resource manager. - - 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, 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 import the resource with the new URI as the storage key - prefixed_uri = f"{prefix}{uri}" if prefix else uri - self.add_resource(resource, key=prefixed_uri) - logger.debug(f'Imported resource "{uri}" as "{prefixed_uri}"') - - 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 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, 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 import the template with the new URI as the storage key - prefixed_uri_template = ( - f"{prefix}{uri_template}" if prefix else uri_template - ) - self.add_template(template, key=prefixed_uri_template) - logger.debug( - f'Imported template "{uri_template}" as "{prefixed_uri_template}"' - ) diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py index d25dc314d..bc65c2ff7 100644 --- a/src/fastmcp/server/openapi.py +++ b/src/fastmcp/server/openapi.py @@ -8,6 +8,7 @@ from re import Pattern from typing import Any, Literal import httpx +from mcp.types import TextContent from pydantic.networks import AnyUrl from fastmcp.resources import Resource, ResourceTemplate @@ -613,25 +614,18 @@ class FastMCPOpenAPI(FastMCP): f"Registered TEMPLATE: {uri_template_str} ({route.method} {route.path}) with tags: {route.tags}" ) - async def call_tool(self, name: str, arguments: dict[str, Any]) -> Any: - """Override the call_tool method to return the raw result without converting to content. + async def _mcp_call_tool(self, name: str, arguments: dict[str, Any]) -> Any: + """Override the call_tool method to return the raw result without converting to content.""" - For testing purposes, if specific tools are called, we convert the result to the expected object. - """ context = self.get_context() result = await self._tool_manager.call_tool(name, arguments, context=context) - # For testing purposes, convert result to expected model based on tool name - if name == "create_user_users_post": - # Try to import User class from test module - try: - from tests.server.test_openapi import User - - # Convert dict to User object - if isinstance(result, dict): - return User(**result) - except ImportError: - # If User class not found, just return the raw result - pass + # For other tools, ensure the response is wrapped in TextContent + if isinstance(result, dict | str): + if isinstance(result, dict): + result_text = json.dumps(result) + else: + result_text = result + return [TextContent(text=result_text, type="text")] return result diff --git a/src/fastmcp/server/proxy.py b/src/fastmcp/server/proxy.py index 887e60efc..e4c31e5c2 100644 --- a/src/fastmcp/server/proxy.py +++ b/src/fastmcp/server/proxy.py @@ -2,10 +2,19 @@ from typing import Any, cast from urllib.parse import quote import mcp.types -from mcp.types import BlobResourceContents, TextResourceContents +from mcp.server.lowlevel.helper_types import ReadResourceContents +from mcp.types import ( + BlobResourceContents, + EmbeddedResource, + GetPromptResult, + ImageContent, + TextContent, + TextResourceContents, +) +from pydantic.networks import AnyUrl -import fastmcp from fastmcp.client import Client +from fastmcp.exceptions import NotFoundError from fastmcp.prompts import Message, Prompt from fastmcp.resources import Resource, ResourceTemplate from fastmcp.server.context import Context @@ -152,79 +161,89 @@ class ProxyPrompt(Prompt): async def render(self, arguments: dict[str, Any]) -> list[Message]: async with self._client: result = await self._client.get_prompt(self.name, arguments) - return [Message(role=m.role, content=m.content) for m in result.messages] + return [Message(role=m.role, content=m.content) for m in result] 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." - ) + def __init__(self, client: "Client", **kwargs): super().__init__(**kwargs) + self.client = client - @classmethod - async def from_client( - cls, - client: "Client", - name: str | None = None, - **settings: fastmcp.settings.ServerSettings, - ) -> "FastMCPProxy": - """Create a FastMCP proxy server from a client. + async def get_tools(self) -> dict[str, Tool]: + tools = await super().get_tools() - 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. + async with self.client: + for tool in await self.client.list_tools(): + tool_proxy = await ProxyTool.from_client(self.client, tool) + tools[tool_proxy.name] = tool_proxy - 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 + return tools - Returns: - A FastMCP server that proxies requests to the client - """ - server = cls(name=name, **settings, _async_constructor=True) + async def get_resources(self) -> dict[str, Resource]: + resources = await super().get_resources() - async with client: - # Register proxies for client tools - tools = await client.list_tools() - for tool in 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}") + async with self.client: + for resource in await self.client.list_resources(): + resource_proxy = await ProxyResource.from_client(self.client, resource) + resources[str(resource_proxy.uri)] = resource_proxy - # Register proxies for client resources - resources = await client.list_resources() - for resource in 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}") + return resources - # Register proxies for client resource templates - templates = await client.list_resource_templates() - for template in templates: - 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}" - ) + async def get_resource_templates(self) -> dict[str, ResourceTemplate]: + templates = await super().get_resource_templates() - # Register proxies for client prompts - prompts = await client.list_prompts() - for prompt in 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}") + async with self.client: + for template in await self.client.list_resource_templates(): + template_proxy = await ProxyTemplate.from_client(self.client, template) + templates[template_proxy.uri_template] = template_proxy - logger.info(f"Created server '{server.name}' proxying to client: {client}") - return server + return templates - @classmethod - async def from_server(cls, server: FastMCP, **settings: Any) -> "FastMCPProxy": - client = Client(transport=fastmcp.client.transports.FastMCPTransport(server)) - return await cls.from_client(client, **settings) + async def get_prompts(self) -> dict[str, Prompt]: + prompts = await super().get_prompts() + + async with self.client: + for prompt in await self.client.list_prompts(): + prompt_proxy = await ProxyPrompt.from_client(self.client, prompt) + prompts[prompt_proxy.name] = prompt_proxy + return prompts + + async def _mcp_call_tool( + self, key: str, arguments: dict[str, Any] + ) -> list[TextContent | ImageContent | EmbeddedResource]: + try: + result = await super()._mcp_call_tool(key, arguments) + return result + except NotFoundError: + async with self.client: + result = await self.client.call_tool(key, arguments) + return result + + async def _mcp_read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]: + try: + result = await super()._mcp_read_resource(uri) + return result + except NotFoundError: + async with self.client: + resource = await self.client.read_resource(uri) + if isinstance(resource[0], TextResourceContents): + content = resource[0].text + elif isinstance(resource[0], BlobResourceContents): + content = resource[0].blob + else: + raise ValueError(f"Unsupported content type: {type(resource[0])}") + + return [ + ReadResourceContents(content=content, mime_type=resource[0].mimeType) + ] + + async def _mcp_get_prompt( + self, name: str, arguments: dict[str, Any] | None = None + ) -> GetPromptResult: + try: + result = await super()._mcp_get_prompt(name, arguments) + return result + except NotFoundError: + async with self.client: + result = await self.client.get_prompt(name, arguments) + return GetPromptResult(messages=result) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index e7b267375..dc52a9f3a 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -1,6 +1,6 @@ """FastMCP - A more ergonomic interface for MCP servers.""" -import json +import datetime from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import ( AbstractAsyncContextManager, @@ -38,24 +38,114 @@ from starlette.routing import Mount, Route import fastmcp import fastmcp.settings -from fastmcp.exceptions import ResourceError +from fastmcp.exceptions import NotFoundError, ResourceError from fastmcp.prompts import Prompt, PromptManager -from fastmcp.prompts.prompt import Message, PromptResult +from fastmcp.prompts.prompt import PromptResult from fastmcp.resources import Resource, ResourceManager from fastmcp.resources.template import ResourceTemplate from fastmcp.tools import ToolManager from fastmcp.tools.tool import Tool from fastmcp.utilities.decorators import DecoratedFunction from fastmcp.utilities.logging import configure_logging, get_logger -from fastmcp.utilities.types import Image if TYPE_CHECKING: from fastmcp.client import Client from fastmcp.server.context import Context from fastmcp.server.openapi import FastMCPOpenAPI from fastmcp.server.proxy import FastMCPProxy + logger = get_logger(__name__) +NOT_FOUND = object() + + +class MountedServer: + def __init__( + self, + prefix: str, + server: "FastMCP", + tool_separator: str | None = None, + resource_separator: str | None = None, + prompt_separator: str | None = None, + cache_expiration_seconds: int = 10, + ): + if tool_separator is None: + tool_separator = "_" + if resource_separator is None: + resource_separator = "+" + if prompt_separator is None: + prompt_separator = "_" + + self.server = server + self.prefix = prefix + self.tool_separator = tool_separator + self.resource_separator = resource_separator + self.prompt_separator = prompt_separator + self.cache = TimedCache( + expiration=datetime.timedelta(seconds=cache_expiration_seconds) + ) + + async def get_tools(self) -> dict[str, Tool]: + cached_tools = self.cache.get("tools") + if cached_tools is NOT_FOUND: + self.cache.set("tools", {}) + cached_tools = await self.server.get_tools() + self.cache.set("tools", cached_tools) + return { + f"{self.prefix}{self.tool_separator}{key}": tool + for key, tool in cached_tools.items() + } + + async def get_resources(self) -> dict[str, Resource]: + cached_resources = self.cache.get("resources") + if cached_resources is NOT_FOUND: + self.cache.set("resources", {}) + cached_resources = await self.server.get_resources() + self.cache.set("resources", cached_resources) + return cached_resources + + async def get_resource_templates(self) -> dict[str, ResourceTemplate]: + cached_templates = self.cache.get("resource_templates") + if cached_templates is NOT_FOUND: + self.cache.set("resource_templates", {}) + cached_templates = await self.server.get_resource_templates() + self.cache.set("resource_templates", cached_templates) + return cached_templates + + async def get_prompts(self) -> dict[str, Prompt]: + cached_prompts = self.cache.get("prompts") + if cached_prompts is NOT_FOUND: + self.cache.set("prompts", {}) + cached_prompts = await self.server.get_prompts() + self.cache.set("prompts", cached_prompts) + return cached_prompts + + async def match_tool(self, key: str) -> bool: + return key.startswith(f"{self.prefix}{self.tool_separator}") + + async def match_resource(self, key: str) -> bool: + return key.startswith(f"{self.prefix}{self.resource_separator}") + + async def match_prompt(self, key: str) -> bool: + return key.startswith(f"{self.prefix}{self.prompt_separator}") + + +class TimedCache: + def __init__(self, expiration: datetime.timedelta): + self.expiration = expiration + self.cache: dict[Any, tuple[Any, datetime.datetime]] = {} + + def set(self, key: Any, value: Any) -> None: + expires = datetime.datetime.now() + self.expiration + self.cache[key] = (value, expires) + + def get(self, key: Any) -> Any: + value = self.cache.get(key) + if value is not None and value[1] > datetime.datetime.now(): + return value[0] + else: + return NOT_FOUND + @asynccontextmanager async def default_lifespan(server: "FastMCP") -> AsyncIterator[Any]: @@ -70,7 +160,7 @@ async def default_lifespan(server: "FastMCP") -> AsyncIterator[Any]: yield {} -def lifespan_wrapper( +def _lifespan_wrapper( app: "FastMCP", lifespan: Callable[["FastMCP"], AbstractAsyncContextManager[LifespanResultT]], ) -> Callable[ @@ -79,17 +169,7 @@ def lifespan_wrapper( @asynccontextmanager async def wrap(s: MCPServer[LifespanResultT]) -> AsyncIterator[LifespanResultT]: async with AsyncExitStack() as stack: - # enter main app's lifespan context = await stack.enter_async_context(lifespan(app)) - - # Enter all mounted app lifespans - for prefix, mounted_app in app._mounted_apps.items(): - mounted_context = mounted_app._mcp_server.lifespan( - mounted_app._mcp_server - ) - await stack.enter_async_context(mounted_context) - logger.debug(f"Prepared lifespan for mounted app '{prefix}'") - yield context return wrap @@ -108,9 +188,13 @@ class FastMCP(Generic[LifespanResultT]): ): self.tags: set[str] = tags or set() self.settings = fastmcp.settings.ServerSettings(**settings) + self._cache = TimedCache( + expiration=datetime.timedelta( + seconds=self.settings.cache_expiration_seconds + ) + ) - # Setup for mounted apps - must be initialized before _mcp_server - self._mounted_apps: dict[str, FastMCP] = {} + self._server_mounts: dict[str, MountedServer] = {} if lifespan is None: lifespan = default_lifespan @@ -118,7 +202,7 @@ class FastMCP(Generic[LifespanResultT]): self._mcp_server = MCPServer[LifespanResultT]( name=name or "FastMCP", instructions=instructions, - lifespan=lifespan_wrapper(self, lifespan), + lifespan=_lifespan_wrapper(self, lifespan), ) self._tool_manager = ToolManager( duplicate_behavior=self.settings.on_duplicate_tools @@ -137,6 +221,9 @@ class FastMCP(Generic[LifespanResultT]): # Configure logging configure_logging(self.settings.log_level) + def __repr__(self) -> str: + return f"{type(self).__name__}({self.name!r})" + @property def name(self) -> str: return self._mcp_server.name @@ -177,26 +264,13 @@ class FastMCP(Generic[LifespanResultT]): def _setup_handlers(self) -> None: """Set up core MCP protocol handlers.""" self._mcp_server.list_tools()(self._mcp_list_tools) - self._mcp_server.call_tool()(self.call_tool) + self._mcp_server.call_tool()(self._mcp_call_tool) self._mcp_server.list_resources()(self._mcp_list_resources) self._mcp_server.read_resource()(self._mcp_read_resource) self._mcp_server.list_prompts()(self._mcp_list_prompts) self._mcp_server.get_prompt()(self._mcp_get_prompt) self._mcp_server.list_resource_templates()(self._mcp_list_resource_templates) - def get_tools(self) -> dict[str, Tool]: - """Get all registered tools, indexed by registered key.""" - return self._tool_manager.get_tools() - - async def _mcp_list_tools(self) -> list[MCPTool]: - """ - List all available tools, in the format expected by the low-level MCP - server. - - See `list_tools` for a more ergonomic way to list tools. - """ - return [tool.to_mcp_tool(name=key) for key, tool in self.get_tools().items()] - def get_context(self) -> "Context[ServerSession, LifespanResultT]": """ Returns a Context object. Note that the context will only be valid @@ -211,52 +285,101 @@ class FastMCP(Generic[LifespanResultT]): return Context(request_context=request_context, fastmcp=self) - async def call_tool( - self, key: str, arguments: dict[str, Any] - ) -> list[TextContent | ImageContent | EmbeddedResource]: - """Call a tool by name with arguments.""" - context = self.get_context() - result = await self._tool_manager.call_tool(key, arguments, context=context) - converted_result = _convert_to_content(result) - return converted_result + async def get_tools(self) -> dict[str, Tool]: + """Get all registered tools, indexed by registered key.""" + tools = {} + for server in self._server_mounts.values(): + server_tools = await server.get_tools() + tools.update(server_tools) + tools.update(self._tool_manager.get_tools()) + return tools - def get_resources(self) -> dict[str, Resource]: + async def get_resources(self) -> dict[str, Resource]: """Get all registered resources, indexed by registered key.""" - return self._resource_manager.get_resources() + resources = {} + for server in self._server_mounts.values(): + server_resources = await server.get_resources() + resources.update(server_resources) + resources.update(self._resource_manager.get_resources()) + return resources - def list_resources(self) -> list[Resource]: - """List all registered resources.""" - return self._resource_manager.list_resources() + async def get_resource_templates(self) -> dict[str, ResourceTemplate]: + """Get all registered resource templates, indexed by registered key.""" + templates = {} + for server in self._server_mounts.values(): + server_templates = await server.get_resource_templates() + templates.update(server_templates) + templates.update(self._resource_manager.get_templates()) + return templates + + async def get_prompts(self) -> dict[str, Prompt]: + """ + List all available prompts. + """ + prompts = {} + for server in self._server_mounts.values(): + server_prompts = await server.get_prompts() + prompts.update(server_prompts) + prompts.update(self._prompt_manager.get_prompts()) + return prompts + + async def _mcp_list_tools(self) -> list[MCPTool]: + """ + List all available tools, in the format expected by the low-level MCP + server. + + """ + tools = await self.get_tools() + return [tool.to_mcp_tool(name=key) for key, tool in tools.items()] async def _mcp_list_resources(self) -> list[MCPResource]: """ List all available resources, in the format expected by the low-level MCP server. - See `list_resources` for a more ergonomic way to list resources. """ - - return self._resource_manager.list_mcp_resources() - - def list_resource_templates(self) -> list[ResourceTemplate]: - return self._resource_manager.list_templates() + resources = await self.get_resources() + return [ + resource.to_mcp_resource(uri=key) for key, resource in resources.items() + ] async def _mcp_list_resource_templates(self) -> list[MCPResourceTemplate]: """ List all available resource templates, in the format expected by the low-level MCP server. - See `list_resource_templates` for a more ergonomic way to list resource - templates. """ - return self._resource_manager.list_mcp_resource_templates() + templates = await self.get_resource_templates() + return [ + template.to_mcp_template(uriTemplate=key) + for key, template in templates.items() + ] - async def read_resource(self, uri: AnyUrl | str) -> str | bytes: - """Read a resource by URI.""" - resource = await self._resource_manager.get_resource(uri) - if not resource: - raise ResourceError(f"Unknown resource: {uri}") - return await resource.read() + async def _mcp_list_prompts(self) -> list[MCPPrompt]: + """ + List all available prompts, in the format expected by the low-level MCP + server. + + """ + prompts = await self.get_prompts() + return [prompt.to_mcp_prompt(name=key) for key, prompt in prompts.items()] + + async def _mcp_call_tool( + self, key: str, arguments: dict[str, Any] + ) -> list[TextContent | ImageContent | EmbeddedResource]: + """Call a tool by name with arguments.""" + if self._tool_manager.get_tool(key): + context = self.get_context() + result = await self._tool_manager.call_tool(key, arguments, context=context) + + else: + for server in self._server_mounts.values(): + if server.match_tool(key): + result = await server.server._mcp_call_tool(key, arguments) + break + else: + raise NotFoundError(f"Unknown tool: {key}") + return result async def _mcp_read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]: """ @@ -265,17 +388,40 @@ class FastMCP(Generic[LifespanResultT]): See `read_resource` for a more ergonomic way to read resources. """ + if resource := await self._resource_manager.get_resource(uri): + try: + content = await resource.read() + return [ + ReadResourceContents(content=content, mime_type=resource.mime_type) + ] + except Exception as e: + logger.error(f"Error reading resource {uri}: {e}") + raise ResourceError(str(e)) + else: + for server in self._server_mounts.values(): + if server.match_resource(str(uri)): + return await server.server._mcp_read_resource(uri) + else: + raise NotFoundError(f"Unknown resource: {uri}") - resource = await self._resource_manager.get_resource(uri) - if not resource: - raise ResourceError(f"Unknown resource: {uri}") + async def _mcp_get_prompt( + self, name: str, arguments: dict[str, Any] | None = None + ) -> GetPromptResult: + """ + Get a prompt by name with arguments, in the format expected by the low-level + MCP server. - try: - content = await self.read_resource(uri) - return [ReadResourceContents(content=content, mime_type=resource.mime_type)] - except Exception as e: - logger.error(f"Error reading resource {uri}: {e}") - raise ResourceError(str(e)) + See `get_prompt` for a more ergonomic way to get prompts. + """ + if prompt := self._prompt_manager.get_prompt(name): + messages = await prompt.render(arguments) + return GetPromptResult(messages=pydantic_core.to_jsonable_python(messages)) + else: + for server in self._server_mounts.values(): + if server.match_prompt(name): + return await server.server._mcp_get_prompt(name, arguments) + else: + raise NotFoundError(f"Unknown prompt: {name}") def add_tool( self, @@ -574,45 +720,30 @@ class FastMCP(Generic[LifespanResultT]): ], ) - def list_prompts(self) -> list[Prompt]: + def mount( + self, + prefix: str, + server: "FastMCP", + tool_separator: str | None = None, + resource_separator: str | None = None, + prompt_separator: str | None = None, + ) -> None: """ - List all available prompts. + Mount another FastMCP server on a given prefix. """ - return self._prompt_manager.list_prompts() + mounted_server = MountedServer( + server=server, + prefix=prefix, + tool_separator=tool_separator, + resource_separator=resource_separator, + prompt_separator=prompt_separator, + ) + self._server_mounts[prefix] = mounted_server - async def _mcp_list_prompts(self) -> list[MCPPrompt]: - """ - List all available prompts, in the format expected by the low-level MCP - server. + def unmount(self, prefix: str) -> None: + self._server_mounts.pop(prefix) - See `list_prompts` for a more ergonomic way to list prompts. - """ - return self._prompt_manager.list_mcp_prompts() - - async def get_prompt( - self, name: str, arguments: dict[str, Any] | None = None - ) -> list[Message]: - """Get a prompt by name with arguments.""" - return await self._prompt_manager.render_prompt(name, arguments) - - async def _mcp_get_prompt( - self, name: str, arguments: dict[str, Any] | None = None - ) -> GetPromptResult: - """ - Get a prompt by name with arguments, in the format expected by the low-level - MCP server. - - See `get_prompt` for a more ergonomic way to get prompts. - """ - try: - messages = await self.get_prompt(name, arguments) - - return GetPromptResult(messages=pydantic_core.to_jsonable_python(messages)) - except Exception as e: - logger.error(f"Error getting prompt {name}: {e}") - raise ValueError(str(e)) - - def import_server( + async def import_server( self, prefix: str, server: "FastMCP", @@ -663,20 +794,20 @@ class FastMCP(Generic[LifespanResultT]): # Import tools from the mounted server tool_prefix = f"{prefix}{tool_separator}" - self._tool_manager.import_tools(server._tool_manager, tool_prefix) + for key, tool in (await server.get_tools()).items(): + self._tool_manager.add_tool(tool, key=f"{tool_prefix}{key}") # Import resources and templates from the mounted server resource_prefix = f"{prefix}{resource_separator}" - self._resource_manager.import_resources( - server._resource_manager, resource_prefix - ) - self._resource_manager.import_templates( - server._resource_manager, resource_prefix - ) + for key, resource in (await server.get_resources()).items(): + self._resource_manager.add_resource(resource, key=f"{resource_prefix}{key}") + for key, template in (await server.get_resource_templates()).items(): + self._resource_manager.add_template(template, key=f"{resource_prefix}{key}") # Import prompts from the mounted server prompt_prefix = f"{prefix}{prompt_separator}" - self._prompt_manager.import_prompts(server._prompt_manager, prompt_prefix) + for key, prompt in (await server.get_prompts()).items(): + self._prompt_manager.add_prompt(prompt, key=f"{prompt_prefix}{key}") logger.info(f"Imported server {server.name} with prefix '{prefix}'") logger.debug(f"Imported tools with prefix '{tool_prefix}'") @@ -684,37 +815,6 @@ class FastMCP(Generic[LifespanResultT]): 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: "Client | FastMCP", **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 fastmcp.client import Client - - from .proxy import FastMCPProxy - - if isinstance(client, Client): - return await FastMCPProxy.from_client(client=client, **settings) - - elif isinstance(client, FastMCP): - return await FastMCPProxy.from_server(server=client, **settings) - - else: - raise ValueError(f"Unknown client type: {type(client)}") - @classmethod def from_openapi( cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, **settings: Any @@ -745,47 +845,11 @@ class FastMCP(Generic[LifespanResultT]): openapi_spec=app.openapi(), client=client, name=name, **settings ) + @classmethod + def from_client(cls, client: "Client", **settings: Any) -> "FastMCPProxy": + """ + Create a FastMCP proxy server from a FastMCP client. + """ + from fastmcp.server.proxy import FastMCPProxy -def _convert_to_content( - result: Any, - _process_as_single_item: bool = False, -) -> list[TextContent | ImageContent | EmbeddedResource]: - """Convert a result to a sequence of content objects.""" - if result is None: - return [] - - if isinstance(result, TextContent | ImageContent | EmbeddedResource): - return [result] - - if isinstance(result, Image): - return [result.to_image_content()] - - if isinstance(result, list | tuple) and not _process_as_single_item: - # if the result is a list, then it could either be a list of MCP types, - # or a "regular" list that the tool is returning, or a mix of both. - # - # so we extract all the MCP types / images and convert them as individual content elements, - # and aggregate the rest as a single content element - - mcp_types = [] - other_content = [] - - for item in result: - if isinstance(item, TextContent | ImageContent | EmbeddedResource | Image): - mcp_types.append(_convert_to_content(item)[0]) - else: - other_content.append(item) - if other_content: - other_content = _convert_to_content( - other_content, _process_as_single_item=True - ) - - return other_content + mcp_types - - if not isinstance(result, str): - try: - result = json.dumps(pydantic_core.to_jsonable_python(result)) - except Exception: - result = str(result) - - return [TextContent(type="text", text=result)] + return FastMCPProxy(client=client, **settings) diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index 41b3fcb89..68401f68b 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -62,6 +62,9 @@ class ServerSettings(BaseSettings): description="List of dependencies to install in the server environment", ) + # cache settings (for checking mounted servers) + cache_expiration_seconds: float = 5 + class ClientSettings(BaseSettings): """FastMCP client settings.""" diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index c289150ea..cfdef7692 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -1,15 +1,18 @@ -from __future__ import annotations as _annotations +from __future__ import annotations import inspect +import json from collections.abc import Callable from typing import TYPE_CHECKING, Annotated, Any +import pydantic_core +from mcp.types import EmbeddedResource, ImageContent, TextContent from mcp.types import Tool as MCPTool from pydantic import BaseModel, BeforeValidator, Field from fastmcp.exceptions import ToolError from fastmcp.utilities.func_metadata import FuncMetadata, func_metadata -from fastmcp.utilities.types import _convert_set_defaults +from fastmcp.utilities.types import Image, _convert_set_defaults if TYPE_CHECKING: from mcp.server.session import ServerSessionT @@ -58,7 +61,7 @@ class Tool(BaseModel): is_async = inspect.iscoroutinefunction(fn) if context_kwarg is None: - if isinstance(fn, classmethod): + if inspect.ismethod(fn) and hasattr(fn, "__func__"): sig = inspect.signature(fn.__func__) else: sig = inspect.signature(fn) @@ -67,14 +70,16 @@ class Tool(BaseModel): context_kwarg = param_name break + # Use callable typing to ensure fn is treated as a callable despite being a classmethod + fn_callable: Callable[..., Any] = fn func_arg_metadata = func_metadata( - fn, + fn_callable, skip_names=[context_kwarg] if context_kwarg is not None else [], ) parameters = func_arg_metadata.arg_model.model_json_schema() return cls( - fn=fn, + fn=fn_callable, name=func_name, description=func_doc, parameters=parameters, @@ -88,10 +93,10 @@ class Tool(BaseModel): self, arguments: dict[str, Any], context: Context[ServerSessionT, LifespanContextT] | None = None, - ) -> Any: + ) -> list[TextContent | ImageContent | EmbeddedResource]: """Run the tool with arguments.""" try: - return await self.fn_metadata.call_fn_with_arg_validation( + result = await self.fn_metadata.call_fn_with_arg_validation( self.fn, self.is_async, arguments, @@ -99,6 +104,7 @@ class Tool(BaseModel): if self.context_kwarg is not None else None, ) + return _convert_to_content(result) except Exception as e: raise ToolError(f"Error executing tool {self.name}: {e}") from e @@ -114,3 +120,48 @@ class Tool(BaseModel): if not isinstance(other, Tool): return False return self.model_dump() == other.model_dump() + + +def _convert_to_content( + result: Any, + _process_as_single_item: bool = False, +) -> list[TextContent | ImageContent | EmbeddedResource]: + """Convert a result to a sequence of content objects.""" + if result is None: + return [] + + if isinstance(result, TextContent | ImageContent | EmbeddedResource): + return [result] + + if isinstance(result, Image): + return [result.to_image_content()] + + if isinstance(result, list | tuple) and not _process_as_single_item: + # if the result is a list, then it could either be a list of MCP types, + # or a "regular" list that the tool is returning, or a mix of both. + # + # so we extract all the MCP types / images and convert them as individual content elements, + # and aggregate the rest as a single content element + + mcp_types = [] + other_content = [] + + for item in result: + if isinstance(item, TextContent | ImageContent | EmbeddedResource | Image): + mcp_types.append(_convert_to_content(item)[0]) + else: + other_content.append(item) + if other_content: + other_content = _convert_to_content( + other_content, _process_as_single_item=True + ) + + return other_content + mcp_types + + if not isinstance(result, str): + try: + result = json.dumps(pydantic_core.to_jsonable_python(result)) + except Exception: + result = str(result) + + return [TextContent(type="text", text=result)] diff --git a/src/fastmcp/tools/tool_manager.py b/src/fastmcp/tools/tool_manager.py index f82c9f5be..6da7d0961 100644 --- a/src/fastmcp/tools/tool_manager.py +++ b/src/fastmcp/tools/tool_manager.py @@ -4,8 +4,9 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Any from mcp.shared.context import LifespanContextT +from mcp.types import EmbeddedResource, ImageContent, TextContent -from fastmcp.exceptions import ToolError +from fastmcp.exceptions import NotFoundError from fastmcp.settings import DuplicateBehavior from fastmcp.tools.tool import Tool from fastmcp.utilities.logging import get_logger @@ -82,29 +83,10 @@ class ToolManager: key: str, arguments: dict[str, Any], context: Context[ServerSessionT, LifespanContextT] | None = None, - ) -> Any: + ) -> list[TextContent | ImageContent | EmbeddedResource]: """Call a tool by name with arguments.""" tool = self.get_tool(key) if not tool: - raise ToolError(f"Unknown tool: {key}") + raise NotFoundError(f"Unknown tool: {key}") return await tool.run(arguments, context=context) - - 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, 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(): - key = f"{prefix}{name}" if prefix else name - self.add_tool(tool, key=key) - logger.debug(f'Imported tool "{tool.name}" as "{key}"') diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 160b32968..17c299e8c 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -67,36 +67,6 @@ def tagged_resources_server(): return server -@pytest.fixture -def mounted_resources_server(): - """Fixture that creates a FastMCP server with mounted resources.""" - # Create the main server - main_server = FastMCP("MainServer") - - # Create sub-app with its own resources - sub_app = FastMCP("SubAppServer") - - # Add a resource to the sub-app - @sub_app.resource(uri="subapp://data", description="SubApp resource") - async def get_subapp_data(): - return {"source": "subapp"} - - # Add a template to the sub-app - @sub_app.resource(uri="subapp://{id}", description="SubApp template") - async def get_subapp_item(id: str): - return {"id": id, "source": "subapp"} - - # Mount the sub-app to the main server with a prefix - main_server.import_server("sub", sub_app) - - # Add a resource to the main server - @main_server.resource(uri="main://data", description="Main resource") - async def get_main_data(): - return {"source": "main"} - - return main_server - - async def test_list_tools(fastmcp_server): """Test listing tools with InMemoryClient.""" client = Client(transport=FastMCPTransport(fastmcp_server)) @@ -296,61 +266,3 @@ async def test_tagged_template_functionality(tagged_resources_server): content_str = str(result[0]) assert '"id": "123"' in content_str assert '"type": "template_data"' in content_str - - -async def test_mounted_resources(mounted_resources_server): - """Test that resources from mounted apps are correctly prefixed.""" - client = Client(transport=FastMCPTransport(mounted_resources_server)) - - async with client: - resources = await client.list_resources() - - # Should have two resources (one from main, one from sub) - assert len(resources) == 2 - - # Find resources by URI - main_resource = next( - (r for r in resources if str(r.uri) == "main://data"), None - ) - sub_resource = next( - (r for r in resources if str(r.uri) == "sub+subapp://data"), None - ) - - # Both resources should exist - assert main_resource is not None - assert sub_resource is not None - - # Check descriptions - assert main_resource.description == "Main resource" - assert sub_resource.description == "SubApp resource" - - -async def test_mounted_templates(mounted_resources_server): - """Test that templates from mounted apps are correctly prefixed.""" - client = Client(transport=FastMCPTransport(mounted_resources_server)) - - async with client: - templates = await client.list_resource_templates() - - # Should have one template (from sub) - assert len(templates) == 1 - - # Check the template - template = templates[0] - assert "sub+subapp://{id}" in template.uriTemplate - assert template.description == "SubApp template" - - -async def test_mounted_template_functionality(mounted_resources_server): - """Test that templates from mounted apps function correctly.""" - client = Client(transport=FastMCPTransport(mounted_resources_server)) - - async with client: - # Use the prefixed template - uri = cast(AnyUrl, "sub+subapp://123") - result = await client.read_resource(uri) - content_str = str(result[0]) - - # Check the content - assert '"id": "123"' in content_str - assert '"source": "subapp"' in content_str diff --git a/tests/prompts/test_base.py b/tests/prompts/test_base.py index 84455d068..24c05ccd8 100644 --- a/tests/prompts/test_base.py +++ b/tests/prompts/test_base.py @@ -12,7 +12,6 @@ from fastmcp.prompts.prompt import ( class TestRenderPrompt: - @pytest.mark.anyio async def test_basic_fn(self): def fn() -> str: return "Hello, world!" @@ -22,7 +21,6 @@ class TestRenderPrompt: UserMessage(content=TextContent(type="text", text="Hello, world!")) ] - @pytest.mark.anyio async def test_async_fn(self): async def fn() -> str: return "Hello, world!" @@ -32,7 +30,6 @@ class TestRenderPrompt: UserMessage(content=TextContent(type="text", text="Hello, world!")) ] - @pytest.mark.anyio async def test_fn_with_args(self): async def fn(name: str, age: int = 30) -> str: return f"Hello, {name}! You're {age} years old." @@ -46,7 +43,6 @@ class TestRenderPrompt: ) ] - @pytest.mark.anyio async def test_fn_with_invalid_kwargs(self): async def fn(name: str, age: int = 30) -> str: return f"Hello, {name}! You're {age} years old." @@ -55,7 +51,6 @@ class TestRenderPrompt: with pytest.raises(ValueError): await prompt.render(arguments=dict(age=40)) - @pytest.mark.anyio async def test_fn_returns_message(self): async def fn() -> Message: return UserMessage(content="Hello, world!") @@ -65,7 +60,6 @@ class TestRenderPrompt: UserMessage(content=TextContent(type="text", text="Hello, world!")) ] - @pytest.mark.anyio async def test_fn_returns_assistant_message(self): async def fn() -> Message: return AssistantMessage( @@ -77,7 +71,6 @@ class TestRenderPrompt: AssistantMessage(content=TextContent(type="text", text="Hello, world!")) ] - @pytest.mark.anyio async def test_fn_returns_multiple_messages(self): expected = [ UserMessage("Hello, world!"), @@ -91,7 +84,6 @@ class TestRenderPrompt: prompt = Prompt.from_function(fn) assert await prompt.render() == expected - @pytest.mark.anyio async def test_fn_returns_list_of_strings(self): expected = [ "Hello, world!", @@ -104,7 +96,6 @@ class TestRenderPrompt: prompt = Prompt.from_function(fn) assert await prompt.render() == [UserMessage(t) for t in expected] - @pytest.mark.anyio async def test_fn_returns_resource_content(self): """Test returning a message with resource content.""" @@ -134,7 +125,6 @@ class TestRenderPrompt: ) ] - @pytest.mark.anyio async def test_fn_returns_mixed_content(self): """Test returning messages with mixed content types.""" @@ -174,7 +164,6 @@ class TestRenderPrompt: ), ] - @pytest.mark.anyio async def test_fn_returns_dict_with_resource(self): """Test returning a dict with resource content.""" diff --git a/tests/prompts/test_prompt_manager.py b/tests/prompts/test_prompt_manager.py index e53148987..4b6063186 100644 --- a/tests/prompts/test_prompt_manager.py +++ b/tests/prompts/test_prompt_manager.py @@ -1,8 +1,8 @@ import pytest -from fastmcp.exceptions import PromptError +from fastmcp.exceptions import NotFoundError from fastmcp.prompts import Prompt -from fastmcp.prompts.prompt import PromptArgument, TextContent, UserMessage +from fastmcp.prompts.prompt import TextContent, UserMessage from fastmcp.prompts.prompt_manager import PromptManager @@ -119,8 +119,8 @@ class TestPromptManager: # Result should be the original prompt assert result.fn.__name__ == "original_fn" - def test_list_prompts(self): - """Test listing all prompts.""" + def test_get_prompts(self): + """Test retrieving all prompts.""" def fn1() -> str: return "Hello, world!" @@ -133,11 +133,11 @@ class TestPromptManager: prompt2 = Prompt.from_function(fn2) manager.add_prompt(prompt1) manager.add_prompt(prompt2) - prompts = manager.list_prompts() + prompts = manager.get_prompts() assert len(prompts) == 2 - assert prompts == [prompt1, prompt2] + assert prompts["fn1"] == prompt1 + assert prompts["fn2"] == prompt2 - @pytest.mark.anyio async def test_render_prompt(self): """Test rendering a prompt.""" @@ -152,7 +152,6 @@ class TestPromptManager: UserMessage(content=TextContent(type="text", text="Hello, world!")) ] - @pytest.mark.anyio async def test_render_prompt_with_args(self): """Test rendering a prompt with arguments.""" @@ -167,14 +166,12 @@ class TestPromptManager: UserMessage(content=TextContent(type="text", text="Hello, World!")) ] - @pytest.mark.anyio async def test_render_unknown_prompt(self): """Test rendering a non-existent prompt.""" manager = PromptManager() - with pytest.raises(PromptError, match="Unknown prompt: unknown"): + with pytest.raises(NotFoundError, match="Unknown prompt: unknown"): await manager.render_prompt("unknown") - @pytest.mark.anyio async def test_render_prompt_with_missing_args(self): """Test rendering a prompt with missing required arguments.""" @@ -253,132 +250,12 @@ class TestPromptTags: ) # Filter prompts by tags - simple_prompts = [p for p in manager.list_prompts() if "simple" in p.tags] + simple_prompts = [ + p for p in manager.get_prompts().values() if "simple" in p.tags + ] assert len(simple_prompts) == 2 assert {p.name for p in simple_prompts} == {"greeting", "summary"} - nlp_prompts = [p for p in manager.list_prompts() if "nlp" in p.tags] + nlp_prompts = [p for p in manager.get_prompts().values() if "nlp" in p.tags] assert len(nlp_prompts) == 1 assert nlp_prompts[0].name == "summary" - - def test_import_prompts_preserves_tags(self): - """Test that importing prompts preserves their tags.""" - source_manager = PromptManager() - - def sample_prompt() -> str: - return "Sample prompt" - - source_manager.add_prompt( - Prompt.from_function(sample_prompt, tags={"example", "test"}) - ) - - target_manager = PromptManager() - target_manager.import_prompts(source_manager, "imported/") - - imported_prompt = target_manager.get_prompt("imported/sample_prompt") - assert imported_prompt is not None - assert imported_prompt.tags == {"example", "test"} - - -class TestImports: - def test_import_prompts(self): - """Test importing prompts from one manager to another with a prefix.""" - # Setup source manager with prompts - source_manager = PromptManager() - - summary_prompt = Prompt( - name="summary", - description="Generate a summary of text", - arguments=[PromptArgument(name="text", description="Text to summarize")], - fn=lambda: None, # type: ignore - ) - source_manager.add_prompt(summary_prompt) - - translate_prompt = Prompt( - name="translate", - description="Translate text to another language", - arguments=[ - PromptArgument(name="text", description="Text to translate"), - PromptArgument(name="language", description="Target language"), - ], - fn=lambda: None, # type: ignore - ) - source_manager.add_prompt(translate_prompt) - - # Create target manager - target_manager = PromptManager() - - # Import prompts from source to target - prefix = "nlp/" - target_manager.import_prompts(source_manager, prefix) - - # Verify prompts were imported with prefixes - assert "nlp/summary" in target_manager._prompts - assert "nlp/translate" in target_manager._prompts - - # Verify the original prompts still exist in source manager - assert "summary" in source_manager._prompts - assert "translate" in source_manager._prompts - - assert target_manager._prompts["nlp/summary"].fn == summary_prompt.fn - assert target_manager._prompts["nlp/translate"].fn == translate_prompt.fn - - def test_import_prompts_with_duplicates(self): - """Test handling of duplicate prompts during import.""" - # Setup source and target managers with same prompt names - source_manager = PromptManager() - target_manager = PromptManager() - - source_prompt = Prompt( - name="common", - description="Source description", - arguments=None, - fn=lambda: None, # type: ignore - ) - source_manager._prompts["common"] = source_prompt - - target_prompt = Prompt( - name="common", - description="Target description", - arguments=None, - fn=lambda: None, # type: ignore - ) - target_manager._prompts["common"] = target_prompt - - # Import prompts with prefix - prefix = "external/" - target_manager.import_prompts(source_manager, prefix) - - # Verify both prompts exist in target manager - assert "common" in target_manager._prompts - assert "external/common" in target_manager._prompts - - assert target_manager._prompts["common"].fn == target_prompt.fn - assert target_manager._prompts["external/common"].fn == source_prompt.fn - - def test_import_prompts_with_nested_prefixes(self): - """Test importing already prefixed prompts.""" - # Setup source manager with already prefixed prompts - first_manager = PromptManager() - second_manager = PromptManager() - third_manager = PromptManager() - - original_prompt = Prompt( - name="analyze", - description="Analyze text", - arguments=[PromptArgument(name="text", description="Text to analyze")], - fn=lambda: None, # type: ignore - ) - first_manager._prompts["analyze"] = original_prompt - - # Import to second manager with prefix - second_manager.import_prompts(first_manager, "text/") - - # Import from second to third with another prefix - third_manager.import_prompts(second_manager, "ai/") - - # Verify the nested prefixing - assert "text/analyze" in second_manager._prompts - assert "ai/text/analyze" in third_manager._prompts - - assert third_manager._prompts["ai/text/analyze"].fn == original_prompt.fn diff --git a/tests/resources/test_file_resources.py b/tests/resources/test_file_resources.py index ef05e32f3..598e9d88d 100644 --- a/tests/resources/test_file_resources.py +++ b/tests/resources/test_file_resources.py @@ -53,7 +53,6 @@ class TestFileResource: assert isinstance(resource.path, Path) assert resource.path.is_absolute() - @pytest.mark.anyio async def test_read_text_file(self, temp_file: Path): """Test reading a text file.""" resource = FileResource( @@ -65,7 +64,6 @@ class TestFileResource: assert content == "test content" assert resource.mime_type == "text/plain" - @pytest.mark.anyio async def test_read_binary_file(self, temp_file: Path): """Test reading a file as binary.""" resource = FileResource( @@ -87,7 +85,6 @@ class TestFileResource: path=Path("test.txt"), ) - @pytest.mark.anyio async def test_missing_file_error(self, temp_file: Path): """Test error when file doesn't exist.""" # Create path to non-existent file @@ -103,7 +100,6 @@ class TestFileResource: @pytest.mark.skipif( os.name == "nt", reason="File permissions behave differently on Windows" ) - @pytest.mark.anyio async def test_permission_error(self, temp_file: Path): """Test reading a file without permissions.""" temp_file.chmod(0o000) # Remove all permissions diff --git a/tests/resources/test_function_resources.py b/tests/resources/test_function_resources.py index 5faba0b88..c179fd26c 100644 --- a/tests/resources/test_function_resources.py +++ b/tests/resources/test_function_resources.py @@ -25,7 +25,6 @@ class TestFunctionResource: assert resource.mime_type == "text/plain" # default assert resource.fn == my_func - @pytest.mark.anyio async def test_read_text(self): """Test reading text from a FunctionResource.""" @@ -41,7 +40,6 @@ class TestFunctionResource: assert content == "Hello, world!" assert resource.mime_type == "text/plain" - @pytest.mark.anyio async def test_read_binary(self): """Test reading binary data from a FunctionResource.""" @@ -56,7 +54,6 @@ class TestFunctionResource: content = await resource.read() assert content == b"Hello, world!" - @pytest.mark.anyio async def test_json_conversion(self): """Test automatic JSON conversion of non-string results.""" @@ -72,7 +69,6 @@ class TestFunctionResource: assert isinstance(content, str) assert '"key": "value"' in content - @pytest.mark.anyio async def test_error_handling(self): """Test error handling in FunctionResource.""" @@ -87,7 +83,6 @@ class TestFunctionResource: with pytest.raises(ValueError, match="Error reading resource function://test"): await resource.read() - @pytest.mark.anyio async def test_basemodel_conversion(self): """Test handling of BaseModel types.""" @@ -102,7 +97,6 @@ class TestFunctionResource: content = await resource.read() assert content == '{"name": "test"}' - @pytest.mark.anyio async def test_custom_type_conversion(self): """Test handling of custom types.""" @@ -121,7 +115,6 @@ class TestFunctionResource: content = await resource.read() assert isinstance(content, str) - @pytest.mark.anyio async def test_async_read_text(self): """Test reading text from async FunctionResource.""" diff --git a/tests/resources/test_resource_manager.py b/tests/resources/test_resource_manager.py index 34f36ff14..293463115 100644 --- a/tests/resources/test_resource_manager.py +++ b/tests/resources/test_resource_manager.py @@ -4,7 +4,7 @@ from tempfile import NamedTemporaryFile import pytest from pydantic import AnyUrl, FileUrl -from fastmcp.exceptions import ResourceError +from fastmcp.exceptions import NotFoundError from fastmcp.resources import ( FileResource, FunctionResource, @@ -36,34 +36,41 @@ class TestResourceManager: def test_add_resource(self, temp_file: Path): """Test adding a resource.""" manager = ResourceManager() + file_url = "file://test-resource" resource = FileResource( - uri=FileUrl(f"file://{temp_file}"), + uri=FileUrl(file_url), name="test", path=temp_file, ) added = manager.add_resource(resource) assert added == resource - assert manager.list_resources() == [resource] + # Get the actual key from the resource manager + assert len(manager.get_resources()) == 1 + assert resource in manager.get_resources().values() def test_add_duplicate_resource(self, temp_file: Path): """Test adding the same resource twice.""" manager = ResourceManager() + file_url = "file://test-resource" resource = FileResource( - uri=FileUrl(f"file://{temp_file}"), + uri=FileUrl(file_url), name="test", path=temp_file, ) first = manager.add_resource(resource) second = manager.add_resource(resource) assert first == second - assert manager.list_resources() == [resource] + # Check the resource is there + assert len(manager.get_resources()) == 1 + assert resource in manager.get_resources().values() def test_warn_on_duplicate_resources(self, temp_file: Path, caplog): """Test warning on duplicate resources.""" manager = ResourceManager(duplicate_behavior="warn") + file_url = "file://test-resource" resource = FileResource( - uri=FileUrl(f"file://{temp_file}"), + uri=FileUrl(file_url), name="test_resource", path=temp_file, ) @@ -73,13 +80,14 @@ class TestResourceManager: assert "Resource already exists" in caplog.text # Should have the resource - assert len(manager.list_resources()) == 1 + assert len(manager.get_resources()) == 1 + assert resource in manager.get_resources().values() def test_disable_warn_on_duplicate_resources(self, temp_file: Path, caplog): """Test disabling warning on duplicate resources.""" manager = ResourceManager(duplicate_behavior="ignore") resource = FileResource( - uri=FileUrl(f"file://{temp_file}"), + uri=FileUrl(f"file://{temp_file.name}"), name="test", path=temp_file, ) @@ -92,7 +100,7 @@ class TestResourceManager: manager = ResourceManager(duplicate_behavior="error") resource = FileResource( - uri=FileUrl(f"file://{temp_file}"), + uri=FileUrl(f"file://{temp_file.name}"), name="test_resource", path=temp_file, ) @@ -106,14 +114,15 @@ class TestResourceManager: """Test replacing duplicate resources.""" manager = ResourceManager(duplicate_behavior="replace") + file_url = "file://test-resource" resource1 = FileResource( - uri=FileUrl(f"file://{temp_file}"), + uri=FileUrl(file_url), name="original", path=temp_file, ) resource2 = FileResource( - uri=FileUrl(f"file://{temp_file}"), + uri=FileUrl(file_url), name="replacement", path=temp_file, ) @@ -122,7 +131,7 @@ class TestResourceManager: manager.add_resource(resource2) # Should have replaced with the new resource - resources = manager.list_resources() + resources = list(manager.get_resources().values()) assert len(resources) == 1 assert resources[0].name == "replacement" @@ -130,14 +139,15 @@ class TestResourceManager: """Test ignoring duplicate resources.""" manager = ResourceManager(duplicate_behavior="ignore") + file_url = "file://test-resource" resource1 = FileResource( - uri=FileUrl(f"file://{temp_file}"), + uri=FileUrl(file_url), name="original", path=temp_file, ) resource2 = FileResource( - uri=FileUrl(f"file://{temp_file}"), + uri=FileUrl(file_url), name="replacement", path=temp_file, ) @@ -146,7 +156,7 @@ class TestResourceManager: result = manager.add_resource(resource2) # Should keep the original - resources = manager.list_resources() + resources = list(manager.get_resources().values()) assert len(resources) == 1 assert resources[0].name == "original" # Result should be the original resource @@ -170,7 +180,7 @@ class TestResourceManager: assert "Template already exists" in caplog.text # Should have the template - assert len(manager.list_templates()) == 1 + assert manager.get_templates() == {"test://{id}": template} def test_error_on_duplicate_templates(self): """Test error on duplicate templates.""" @@ -216,7 +226,7 @@ class TestResourceManager: manager.add_template(template2) # Should have replaced with the new template - templates = manager.list_templates() + templates = list(manager.get_templates().values()) assert len(templates) == 1 assert templates[0].name == "replacement" @@ -246,18 +256,17 @@ class TestResourceManager: result = manager.add_template(template2) # Should keep the original - templates = manager.list_templates() + templates = list(manager.get_templates().values()) assert len(templates) == 1 assert templates[0].name == "original" # Result should be the original template assert result.name == "original" - @pytest.mark.anyio async def test_get_resource(self, temp_file: Path): """Test getting a resource by URI.""" manager = ResourceManager() resource = FileResource( - uri=FileUrl(f"file://{temp_file}"), + uri=FileUrl(f"file://{temp_file.name}"), name="test", path=temp_file, ) @@ -265,7 +274,6 @@ class TestResourceManager: retrieved = await manager.get_resource(resource.uri) assert retrieved == resource - @pytest.mark.anyio async def test_get_resource_from_template(self): """Test getting a resource through a template.""" manager = ResourceManager() @@ -285,31 +293,34 @@ class TestResourceManager: content = await resource.read() assert content == "Hello, world!" - @pytest.mark.anyio async def test_get_unknown_resource(self): """Test getting a non-existent resource.""" manager = ResourceManager() - with pytest.raises(ResourceError, match="Unknown resource"): + with pytest.raises(NotFoundError, match="Unknown resource"): await manager.get_resource(AnyUrl("unknown://test")) - def test_list_resources(self, temp_file: Path): - """Test listing all resources.""" + def test_get_resources(self, temp_file: Path): + """Test retrieving all resources.""" manager = ResourceManager() + file_url1 = "file://test-resource1" resource1 = FileResource( - uri=FileUrl(f"file://{temp_file}"), + uri=FileUrl(file_url1), name="test1", path=temp_file, ) + file_url2 = "file://test-resource2" resource2 = FileResource( - uri=FileUrl(f"file://{temp_file}2"), + uri=FileUrl(file_url2), name="test2", path=temp_file, ) manager.add_resource(resource1) manager.add_resource(resource2) - resources = manager.list_resources() + resources = manager.get_resources() assert len(resources) == 2 - assert resources == [resource1, resource2] + values = list(resources.values()) + assert resource1 in values + assert resource2 in values class TestResourceTags: @@ -319,7 +330,7 @@ class TestResourceTags: """Test adding a resource with tags.""" manager = ResourceManager() resource = FileResource( - uri=FileUrl(f"file://{temp_file}"), + uri=FileUrl("file://weather-data"), name="weather_data", path=temp_file, tags={"weather", "data"}, @@ -327,7 +338,7 @@ class TestResourceTags: manager.add_resource(resource) # Check that tags are preserved - resources = manager.list_resources() + resources = list(manager.get_resources().values()) assert len(resources) == 1 assert resources[0].tags == {"weather", "data"} @@ -348,7 +359,7 @@ class TestResourceTags: ) manager.add_resource(resource) - resources = manager.list_resources() + resources = list(manager.get_resources().values()) assert len(resources) == 1 assert resources[0].tags == {"sample", "test", "data"} @@ -368,7 +379,7 @@ class TestResourceTags: ) manager.add_template(template) - templates = manager.list_templates() + templates = list(manager.get_templates().values()) assert len(templates) == 1 assert templates[0].tags == {"users", "template", "data"} @@ -378,7 +389,7 @@ class TestResourceTags: # Create multiple resources with different tags resource1 = FileResource( - uri=FileUrl(f"file://{temp_file}1"), + uri=FileUrl("file://weather-data"), name="weather_data", path=temp_file, tags={"weather", "external"}, @@ -410,285 +421,17 @@ class TestResourceTags: # Filter resources by tags internal_resources = [ - r for r in manager.list_resources() if "internal" in r.tags + r for r in manager.get_resources().values() if "internal" in r.tags ] assert len(internal_resources) == 2 assert {r.name for r in internal_resources} == {"user_data", "system_data"} external_resources = [ - r for r in manager.list_resources() if "external" in r.tags + r for r in manager.get_resources().values() if "external" in r.tags ] assert len(external_resources) == 1 assert external_resources[0].name == "weather_data" - def test_import_resources_preserves_tags(self): - """Test that importing resources preserves their tags.""" - source_manager = ResourceManager() - - async def get_data(): - return "Tagged data" - - resource = FunctionResource( - uri=AnyUrl("data://tagged"), - name="tagged_data", - fn=get_data, - tags={"test", "example", "data"}, - ) - - source_manager.add_resource(resource) - - target_manager = ResourceManager() - target_manager.import_resources(source_manager, "imported+") - - imported_resources = target_manager.list_resources() - assert len(imported_resources) == 1 - assert imported_resources[0].tags == {"test", "example", "data"} - - def test_import_templates_preserves_tags(self): - """Test that importing templates preserves their tags.""" - source_manager = ResourceManager() - - def user_template(user_id: str) -> str: - return f"User {user_id}" - - template = ResourceTemplate.from_function( - fn=user_template, - uri_template="users://{user_id}", - name="user_template", - tags={"users", "template", "test"}, - ) - - source_manager.add_template(template) - - target_manager = ResourceManager() - target_manager.import_templates(source_manager, "imported+") - - imported_templates = target_manager.list_templates() - assert len(imported_templates) == 1 - assert imported_templates[0].tags == {"users", "template", "test"} - - -class TestImports: - def test_import_resources(self): - """Test importing resources from one manager to another with a prefix.""" - # Setup source manager with resources - source_manager = ResourceManager() - - # Create mock resource functions - async def weather_fn(): - return "Weather data" - - async def traffic_fn(): - return "Traffic data" - - # Add resources to source manager - weather_resource = FunctionResource( - uri=AnyUrl("weather://forecast"), - name="weather_forecast", - description="Get weather forecast", - mime_type="application/json", - fn=weather_fn, - ) - source_manager._resources["weather://forecast"] = weather_resource - - traffic_resource = FunctionResource( - uri=AnyUrl("traffic://status"), - name="traffic_status", - description="Get traffic status", - mime_type="application/json", - fn=traffic_fn, - ) - source_manager._resources["traffic://status"] = traffic_resource - - # Create target manager - target_manager = ResourceManager() - - # Import resources from source to target - prefix = "data+" - target_manager.import_resources(source_manager, prefix) - - # Verify resources were imported with prefixes - assert "data+weather://forecast" in target_manager._resources - assert "data+traffic://status" in target_manager._resources - - # Verify the original resources still exist in source manager - assert "weather://forecast" in source_manager._resources - assert "traffic://status" in source_manager._resources - - # Verify the imported resources have the correct properties - assert ( - target_manager._resources["data+weather://forecast"].name - == "weather_forecast" - ) - assert ( - target_manager._resources["data+weather://forecast"].description - == "Get weather forecast" - ) - assert ( - target_manager._resources["data+weather://forecast"].mime_type - == "application/json" - ) - - assert ( - target_manager._resources["data+traffic://status"].name == "traffic_status" - ) - assert ( - target_manager._resources["data+traffic://status"].description - == "Get traffic status" - ) - assert ( - target_manager._resources["data+traffic://status"].mime_type - == "application/json" - ) - - # Since we're dealing with FunctionResource type, we can safely check function attributes - assert isinstance( - target_manager._resources["data+weather://forecast"], FunctionResource - ) - assert isinstance( - target_manager._resources["data+traffic://status"], FunctionResource - ) - - weather_resource = target_manager._resources["data+weather://forecast"] - traffic_resource = target_manager._resources["data+traffic://status"] - - if hasattr(weather_resource, "fn") and hasattr(traffic_resource, "fn"): - assert weather_resource.fn.__name__ == weather_fn.__name__ - assert traffic_resource.fn.__name__ == traffic_fn.__name__ - - def test_import_templates(self): - """Test importing resource templates from one manager to another with a prefix.""" - # Setup source manager with templates - source_manager = ResourceManager() - - # Create mock template functions - async def user_fn(**params): - return f"User data for id {params.get('id')}" - - async def product_fn(**params): - return f"Product data for id {params.get('id')}" - - # Add templates to source manager - user_template = ResourceTemplate( - uri_template="api://users/{id}", - name="user_template", - description="Get user by ID", - mime_type="application/json", - fn=user_fn, - parameters={"id": {"type": "string", "description": "User ID"}}, - ) - source_manager._templates["api://users/{id}"] = user_template - - product_template = ResourceTemplate( - uri_template="api://products/{id}", - name="product_template", - description="Get product by ID", - mime_type="application/json", - fn=product_fn, - parameters={"id": {"type": "string", "description": "Product ID"}}, - ) - source_manager._templates["api://products/{id}"] = product_template - - # Create target manager - target_manager = ResourceManager() - - # Import templates from source to target - prefix = "shop+" - target_manager.import_templates(source_manager, prefix) - - # Verify templates were imported with prefixes - assert "shop+api://users/{id}" in target_manager._templates - assert "shop+api://products/{id}" in target_manager._templates - - # Verify the original templates still exist in source manager - assert "api://users/{id}" in source_manager._templates - assert "api://products/{id}" in source_manager._templates - - # Verify the imported templates have the correct properties - assert ( - target_manager._templates["shop+api://users/{id}"].name == "user_template" - ) - assert ( - target_manager._templates["shop+api://users/{id}"].description - == "Get user by ID" - ) - assert ( - target_manager._templates["shop+api://users/{id}"].mime_type - == "application/json" - ) - assert target_manager._templates["shop+api://users/{id}"].parameters == { - "id": {"type": "string", "description": "User ID"} - } - - assert ( - target_manager._templates["shop+api://products/{id}"].name - == "product_template" - ) - assert ( - target_manager._templates["shop+api://products/{id}"].description - == "Get product by ID" - ) - assert ( - target_manager._templates["shop+api://products/{id}"].mime_type - == "application/json" - ) - assert target_manager._templates["shop+api://products/{id}"].parameters == { - "id": {"type": "string", "description": "Product ID"} - } - - # Verify the template functions were properly copied (only if the fn attribute exists) - user_template = target_manager._templates["shop+api://users/{id}"] - product_template = target_manager._templates["shop+api://products/{id}"] - - if hasattr(user_template, "fn") and hasattr(product_template, "fn"): - assert user_template.fn.__name__ == user_fn.__name__ - assert product_template.fn.__name__ == product_fn.__name__ - - def test_import_multiple_resource_types(self): - """Test importing both resources and templates with the same prefix.""" - # Setup source manager with both resources and templates - source_manager = ResourceManager() - - # Create mock functions - async def resource_fn(): - return "Resource data" - - async def template_fn(**params): - return f"Template data for id {params.get('id')}" - - # Add a resource to source manager - resource = FunctionResource( - uri=AnyUrl("data://resource"), - name="test_resource", - description="Test resource", - mime_type="application/json", - fn=resource_fn, - ) - source_manager._resources["data://resource"] = resource - - # Add a template to source manager - template = ResourceTemplate( - uri_template="data://template/{id}", - name="test_template", - description="Test template", - mime_type="application/json", - fn=template_fn, - parameters={"id": {"type": "string", "description": "ID parameter"}}, - ) - source_manager._templates["data://template/{id}"] = template - - # Create target manager - target_manager = ResourceManager() - - # Import both resources and templates - prefix = "test+" - target_manager.import_resources(source_manager, prefix) - target_manager.import_templates(source_manager, prefix) - - # Verify both resource types were imported with prefixes - assert "test+data://resource" in target_manager._resources - assert "test+data://template/{id}" in target_manager._templates - class TestCustomResourceKeys: """Test adding resources and templates with custom keys.""" @@ -743,7 +486,6 @@ class TestCustomResourceKeys: # The template's internal URI template remains unchanged assert str(manager._templates[custom_key].uri_template) == original_uri_template - @pytest.mark.anyio async def test_get_resource_with_custom_key(self, temp_file: Path): """Test that get_resource works with resources added with custom keys.""" manager = ResourceManager() @@ -768,10 +510,9 @@ class TestCustomResourceKeys: assert str(retrieved.uri) == original_uri # Should NOT be retrievable by the original URI - with pytest.raises(ResourceError, match="Unknown resource"): + with pytest.raises(NotFoundError, match="Unknown resource"): await manager.get_resource(original_uri) - @pytest.mark.anyio async def test_get_resource_from_template_with_custom_key(self): """Test that templates with custom keys can create resources.""" manager = ResourceManager() @@ -797,63 +538,5 @@ class TestCustomResourceKeys: assert content == "Hello, world!" # Shouldn't work with the original template pattern - with pytest.raises(ResourceError, match="Unknown resource"): + with pytest.raises(NotFoundError, match="Unknown resource"): await manager.get_resource("greet://world") - - def test_import_resources_with_custom_keys(self): - """Test that import_resources properly uses custom keys.""" - source_manager = ResourceManager() - target_manager = ResourceManager() - - # Add a resource to source manager - async def resource_fn(): - return "Resource data" - - original_uri = "data://original" - resource = FunctionResource( - uri=AnyUrl(original_uri), - name="original_resource", - fn=resource_fn, - ) - source_manager.add_resource(resource) - - # Import with prefix which creates a new key - prefix = "imported+" - target_manager.import_resources(source_manager, prefix) - - # Resource should be in target manager with prefixed URI as key - prefixed_uri = f"{prefix}{original_uri}" - assert prefixed_uri in target_manager._resources - - # The resource's internal URI should remain unchanged - stored_resource = target_manager._resources[prefixed_uri] - assert str(stored_resource.uri) == original_uri - - def test_import_templates_with_custom_keys(self): - """Test that import_templates properly uses custom keys.""" - source_manager = ResourceManager() - target_manager = ResourceManager() - - # Add a template to source manager - async def template_fn(id: str): - return f"Template {id}" - - original_template = "template://{id}" - template = ResourceTemplate.from_function( - fn=template_fn, - uri_template=original_template, - name="original_template", - ) - source_manager.add_template(template) - - # Import with prefix which creates a new key - prefix = "imported+" - target_manager.import_templates(source_manager, prefix) - - # Template should be in target manager with prefixed URI template as key - prefixed_template = f"{prefix}{original_template}" - assert prefixed_template in target_manager._templates - - # The template's internal URI template should remain unchanged - stored_template = target_manager._templates[prefixed_template] - assert str(stored_template.uri_template) == original_template diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py index 301a48297..a8b01c11c 100644 --- a/tests/resources/test_resource_template.py +++ b/tests/resources/test_resource_template.py @@ -162,7 +162,6 @@ class TestResourceTemplate: name="test", ) - @pytest.mark.anyio async def test_create_resource(self): """Test creating a resource from a template.""" @@ -186,7 +185,6 @@ class TestResourceTemplate: data = json.loads(content) assert data == {"key": "foo", "value": 123} - @pytest.mark.anyio async def test_template_error(self): """Test error handling in template resource creation.""" @@ -202,7 +200,6 @@ class TestResourceTemplate: with pytest.raises(ValueError, match="Error creating resource from template"): await template.create_resource("fail://test", {"x": "test"}) - @pytest.mark.anyio async def test_async_text_resource(self): """Test creating a text resource from async function.""" @@ -224,7 +221,6 @@ class TestResourceTemplate: content = await resource.read() assert content == "Hello, world!" - @pytest.mark.anyio async def test_async_binary_resource(self): """Test creating a binary resource from async function.""" @@ -246,7 +242,6 @@ class TestResourceTemplate: content = await resource.read() assert content == b"test" - @pytest.mark.anyio async def test_basemodel_conversion(self): """Test handling of BaseModel types.""" @@ -274,7 +269,6 @@ class TestResourceTemplate: data = json.loads(content) assert data == {"key": "foo", "value": 123} - @pytest.mark.anyio async def test_custom_type_conversion(self): """Test handling of custom types.""" diff --git a/tests/resources/test_resources.py b/tests/resources/test_resources.py index 870002c35..9eb3d3721 100644 --- a/tests/resources/test_resources.py +++ b/tests/resources/test_resources.py @@ -90,7 +90,6 @@ class TestResourceValidation: ) assert resource.mime_type == "application/json" - @pytest.mark.anyio async def test_resource_read_abstract(self): """Test that Resource.read() is abstract.""" diff --git a/tests/server/test_file_server.py b/tests/server/test_file_server.py index 6971c5ebd..b483ac110 100644 --- a/tests/server/test_file_server.py +++ b/tests/server/test_file_server.py @@ -73,7 +73,6 @@ def tools(mcp: FastMCP, test_dir: Path) -> FastMCP: return mcp -@pytest.mark.anyio async def test_list_resources(mcp: FastMCP): resources = await mcp._mcp_list_resources() assert len(resources) == 4 @@ -86,7 +85,6 @@ async def test_list_resources(mcp: FastMCP): ] -@pytest.mark.anyio async def test_read_resource_dir(mcp: FastMCP): res_iter = await mcp._mcp_read_resource("dir://test_dir") res_list = list(res_iter) @@ -103,7 +101,6 @@ async def test_read_resource_dir(mcp: FastMCP): ] -@pytest.mark.anyio async def test_read_resource_file(mcp: FastMCP): res_iter = await mcp._mcp_read_resource("file://test_dir/example.py") res_list = list(res_iter) @@ -112,17 +109,15 @@ async def test_read_resource_file(mcp: FastMCP): assert res.content == "print('hello world')" -@pytest.mark.anyio async def test_delete_file(mcp: FastMCP, test_dir: Path): - await mcp.call_tool( + await mcp._mcp_call_tool( "delete_file", arguments=dict(path=str(test_dir / "example.py")) ) assert not (test_dir / "example.py").exists() -@pytest.mark.anyio async def test_delete_file_and_check_resources(mcp: FastMCP, test_dir: Path): - await mcp.call_tool( + await mcp._mcp_call_tool( "delete_file", arguments=dict(path=str(test_dir / "example.py")) ) res_iter = await mcp._mcp_read_resource("file://test_dir/example.py") diff --git a/tests/server/test_import_server.py b/tests/server/test_import_server.py index 26e230c84..4583d4406 100644 --- a/tests/server/test_import_server.py +++ b/tests/server/test_import_server.py @@ -1,15 +1,14 @@ -import contextlib import json from urllib.parse import quote -import pytest -from mcp.types import TextContent +from mcp.types import TextContent, TextResourceContents +from fastmcp.client.client import Client from fastmcp.server.server import FastMCP -async def test_mount_basic_functionality(): - """Test that the mount method properly imports tools and other resources.""" +async def test_import_basic_functionality(): + """Test that the import method properly imports tools and other resources.""" # Create main app and sub-app main_app = FastMCP("MainApp") sub_app = FastMCP("SubApp") @@ -19,8 +18,8 @@ async def test_mount_basic_functionality(): def sub_tool() -> str: return "This is from the sub app" - # Mount the sub-app to the main app - main_app.import_server("sub", sub_app) + # Import the sub-app to the main app + await main_app.import_server("sub", sub_app) # Verify the tool was imported with the prefix assert "sub_sub_tool" in main_app._tool_manager._tools @@ -33,8 +32,8 @@ async def test_mount_basic_functionality(): assert callable(tool.fn) -async def test_mount_multiple_apps(): - """Test mounting multiple apps to a main app.""" +async def test_import_multiple_apps(): + """Test importing multiple apps to a main app.""" # Create main app and multiple sub-apps main_app = FastMCP("MainApp") weather_app = FastMCP("WeatherApp") @@ -49,17 +48,17 @@ async def test_mount_multiple_apps(): def get_headlines() -> str: return "News headlines" - # Mount both sub-apps to the main app - main_app.import_server("weather", weather_app) - main_app.import_server("news", news_app) + # Import both sub-apps to the main app + await main_app.import_server("weather", weather_app) + await main_app.import_server("news", news_app) # Verify tools were imported with the correct prefixes assert "weather_get_forecast" in main_app._tool_manager._tools assert "news_get_headlines" in main_app._tool_manager._tools -async def test_mount_combines_tools(): - """Test that mounting preserves existing tools with the same prefix.""" +async def test_import_combines_tools(): + """Test that importing preserves existing tools with the same prefix.""" # Create apps main_app = FastMCP("MainApp") first_app = FastMCP("FirstApp") @@ -74,22 +73,22 @@ async def test_mount_combines_tools(): def second_tool() -> str: return "Second app tool" - # Mount first app - main_app.import_server("api", first_app) + # Import first app + await main_app.import_server("api", first_app) assert "api_first_tool" in main_app._tool_manager._tools - # Mount second app to same prefix - main_app.import_server("api", second_app) + # Import second app to same prefix + await main_app.import_server("api", second_app) # Verify second tool is there assert "api_second_tool" in main_app._tool_manager._tools - # Tools from both mounts are combined + # Tools from both imports are combined assert "api_first_tool" in main_app._tool_manager._tools -async def test_mount_with_resources(): - """Test mounting with resources.""" +async def test_import_with_resources(): + """Test importing with resources.""" # Create apps main_app = FastMCP("MainApp") data_app = FastMCP("DataApp") @@ -99,15 +98,15 @@ async def test_mount_with_resources(): async def get_users(): return ["user1", "user2"] - # Mount the data app - main_app.import_server("data", data_app) + # Import the data app + await main_app.import_server("data", data_app) # Verify the resource was imported with the prefix assert "data+data://users" in main_app._resource_manager._resources -async def test_mount_with_resource_templates(): - """Test mounting with resource templates.""" +async def test_import_with_resource_templates(): + """Test importing with resource templates.""" # Create apps main_app = FastMCP("MainApp") user_app = FastMCP("UserApp") @@ -117,15 +116,15 @@ async def test_mount_with_resource_templates(): def get_user_profile(user_id: str) -> dict: return {"id": user_id, "name": f"User {user_id}"} - # Mount the user app - main_app.import_server("api", user_app) + # Import the user app + await main_app.import_server("api", user_app) # Verify the template was imported with the prefix assert "api+users://{user_id}/profile" in main_app._resource_manager._templates -async def test_mount_with_prompts(): - """Test mounting with prompts.""" +async def test_import_with_prompts(): + """Test importing with prompts.""" # Create apps main_app = FastMCP("MainApp") assistant_app = FastMCP("AssistantApp") @@ -135,15 +134,15 @@ async def test_mount_with_prompts(): def greeting(name: str) -> str: return f"Hello, {name}!" - # Mount the assistant app - main_app.import_server("assistant", assistant_app) + # Import the assistant app + await main_app.import_server("assistant", assistant_app) # Verify the prompt was imported with the prefix assert "assistant_greeting" in main_app._prompt_manager._prompts -async def test_mount_multiple_resource_templates(): - """Test mounting multiple apps with resource templates.""" +async def test_import_multiple_resource_templates(): + """Test importing multiple apps with resource templates.""" # Create apps main_app = FastMCP("MainApp") weather_app = FastMCP("WeatherApp") @@ -158,17 +157,17 @@ async def test_mount_multiple_resource_templates(): def get_news(category: str) -> str: return f"News for {category}" - # Mount both apps - main_app.import_server("data", weather_app) - main_app.import_server("content", news_app) + # Import both apps + await main_app.import_server("data", weather_app) + await main_app.import_server("content", news_app) # Verify templates were imported with correct prefixes assert "data+weather://{city}" in main_app._resource_manager._templates assert "content+news://{category}" in main_app._resource_manager._templates -async def test_mount_multiple_prompts(): - """Test mounting multiple apps with prompts.""" +async def test_import_multiple_prompts(): + """Test importing multiple apps with prompts.""" # Create apps main_app = FastMCP("MainApp") python_app = FastMCP("PythonApp") @@ -183,61 +182,17 @@ async def test_mount_multiple_prompts(): def explain_sql(query: str) -> str: return f"Explaining SQL query:\n{query}" - # Mount both apps - main_app.import_server("python", python_app) - main_app.import_server("sql", sql_app) + # Import both apps + await main_app.import_server("python", python_app) + await main_app.import_server("sql", sql_app) # Verify prompts were imported with correct prefixes assert "python_review_python" in main_app._prompt_manager._prompts assert "sql_explain_sql" in main_app._prompt_manager._prompts -@pytest.mark.xfail(reason="Lifespans are not yet implemented") -async def test_mount_lifespan(): - """Test that the lifespan of a mounted app is properly handled.""" - # Create apps - - lifespan_checkpoints = [] - - @contextlib.asynccontextmanager - async def lifespan(app: FastMCP): - lifespan_checkpoints.append(f"enter {app.name}") - try: - yield - finally: - lifespan_checkpoints.append(f"exit {app.name}") - - main_app = FastMCP("MainApp", lifespan=lifespan) - sub_app = FastMCP("SubApp", lifespan=lifespan) - sub_app_2 = FastMCP("SubApp2", lifespan=lifespan) - - main_app.import_server("sub", sub_app) - main_app.import_server("sub2", sub_app_2) - - low_level_server = main_app._mcp_server - async with contextlib.AsyncExitStack() as stack: - # Note: this imitates the way that lifespans are entered for mounted - # apps It is presently difficult to stop a running server - # programmatically without error in order to test the exit conditions, - # so this is the next best thing - await stack.enter_async_context(low_level_server.lifespan(low_level_server)) - assert lifespan_checkpoints == [ - "enter MainApp", - "enter SubApp", - "enter SubApp2", - ] - assert lifespan_checkpoints == [ - "enter MainApp", - "enter SubApp", - "enter SubApp2", - "exit SubApp2", - "exit SubApp", - "exit MainApp", - ] - - -async def test_tool_custom_name_preserved_when_mounted(): - """Test that a tool's custom name is preserved when mounted.""" +async def test_tool_custom_name_preserved_when_imported(): + """Test that a tool's custom name is preserved when imported.""" main_app = FastMCP("MainApp") api_app = FastMCP("APIApp") @@ -245,7 +200,7 @@ async def test_tool_custom_name_preserved_when_mounted(): return f"Data for query: {query}" api_app.add_tool(fetch_data, name="get_data") - main_app.import_server("api", api_app) + await main_app.import_server("api", api_app) # Check that the tool is accessible by its prefixed name tool = main_app._tool_manager.get_tool("api_get_data") @@ -255,8 +210,8 @@ async def test_tool_custom_name_preserved_when_mounted(): assert tool.fn.__name__ == "fetch_data" -async def test_call_mounted_custom_named_tool(): - """Test calling a mounted tool with a custom name.""" +async def test_call_imported_custom_named_tool(): + """Test calling an imported tool with a custom name.""" main_app = FastMCP("MainApp") api_app = FastMCP("APIApp") @@ -264,17 +219,18 @@ async def test_call_mounted_custom_named_tool(): return f"Data for query: {query}" api_app.add_tool(fetch_data, name="get_data") - main_app.import_server("api", api_app) + await main_app.import_server("api", api_app) context = main_app.get_context() result = await main_app._tool_manager.call_tool( "api_get_data", {"query": "test"}, context=context ) - assert result == "Data for query: test" + assert isinstance(result[0], TextContent) + assert result[0].text == "Data for query: test" -async def test_first_level_mounting_with_custom_name(): - """Test that a tool with a custom name is correctly mounted at the first level.""" +async def test_first_level_importing_with_custom_name(): + """Test that a tool with a custom name is correctly imported at the first level.""" service_app = FastMCP("ServiceApp") provider_app = FastMCP("ProviderApp") @@ -282,7 +238,7 @@ async def test_first_level_mounting_with_custom_name(): return input * 2 provider_app.add_tool(calculate_value, name="compute") - service_app.import_server("provider", provider_app) + await service_app.import_server("provider", provider_app) # Tool is accessible in the service app with the first prefix tool = service_app._tool_manager.get_tool("provider_compute") @@ -290,8 +246,8 @@ async def test_first_level_mounting_with_custom_name(): assert tool.fn.__name__ == "calculate_value" -async def test_nested_mounting_preserves_prefixes(): - """Test that mounting a previously mounted app preserves prefixes.""" +async def test_nested_importing_preserves_prefixes(): + """Test that importing a previously imported app preserves prefixes.""" main_app = FastMCP("MainApp") service_app = FastMCP("ServiceApp") provider_app = FastMCP("ProviderApp") @@ -300,16 +256,16 @@ async def test_nested_mounting_preserves_prefixes(): return input * 2 provider_app.add_tool(calculate_value, name="compute") - service_app.import_server("provider", provider_app) - main_app.import_server("service", service_app) + await service_app.import_server("provider", provider_app) + await main_app.import_server("service", service_app) # Tool is accessible in the main app with both prefixes tool = main_app._tool_manager.get_tool("service_provider_compute") assert tool is not None -async def test_call_nested_mounted_tool(): - """Test calling a tool through multiple levels of mounting.""" +async def test_call_nested_imported_tool(): + """Test calling a tool through multiple levels of importing.""" main_app = FastMCP("MainApp") service_app = FastMCP("ServiceApp") provider_app = FastMCP("ProviderApp") @@ -318,18 +274,19 @@ async def test_call_nested_mounted_tool(): return input * 2 provider_app.add_tool(calculate_value, name="compute") - service_app.import_server("provider", provider_app) - main_app.import_server("service", service_app) + await service_app.import_server("provider", provider_app) + await main_app.import_server("service", service_app) result = await main_app._tool_manager.call_tool( "service_provider_compute", {"input": 21} ) - assert result == 42 + assert isinstance(result[0], TextContent) + assert result[0].text == "42" -async def test_mount_with_proxy_tools(): +async def test_import_with_proxy_tools(): """ - Test mounting with tools that have custom names (proxy tools). + Test importing with tools that have custom names (proxy tools). This tests that the tool's name doesn't change even though the registered name does, which is important because we need to forward that name to the @@ -343,16 +300,17 @@ async def test_mount_with_proxy_tools(): def get_data(query: str) -> str: return f"Data for query: {query}" - main_app.import_server("api", await FastMCP.as_proxy(api_app)) + proxy_app = FastMCP.from_client(Client(api_app)) + await main_app.import_server("api", proxy_app) - result = await main_app.call_tool("api_get_data", {"query": "test"}) + result = await main_app._mcp_call_tool("api_get_data", {"query": "test"}) assert isinstance(result[0], TextContent) assert result[0].text == "Data for query: test" -async def test_mount_with_proxy_prompts(): +async def test_import_with_proxy_prompts(): """ - Test mounting with prompts that have custom keys. + Test importing with prompts that have custom keys. This tests that the prompt's name doesn't change even though the registered key does, which is important for correct rendering. @@ -365,17 +323,17 @@ async def test_mount_with_proxy_prompts(): def greeting(name: str) -> str: return f"Hello, {name} from API!" - main_app.import_server("api", await FastMCP.as_proxy(api_app)) + proxy_app = FastMCP.from_client(Client(api_app)) + await main_app.import_server("api", proxy_app) - result = await main_app.get_prompt("api_greeting", {"name": "World"}) - assert len(result) > 0 - assert isinstance(result[0].content, TextContent) - assert result[0].content.text == "Hello, World from API!" + result = await main_app._mcp_get_prompt("api_greeting", {"name": "World"}) + assert result.messages is not None + # Check that the message contains our greeting -async def test_mount_with_proxy_resources(): +async def test_import_with_proxy_resources(): """ - Test mounting with resources that have custom keys. + Test importing with resources that have custom keys. This tests that the resource's name doesn't change even though the registered key does, which is important for correct access. @@ -392,19 +350,21 @@ async def test_mount_with_proxy_resources(): "base_url": "https://api.example.com", } - main_app.import_server("api", await FastMCP.as_proxy(api_app)) + proxy_app = FastMCP.from_client(Client(api_app)) + await main_app.import_server("api", proxy_app) # Access the resource through the main app with the prefixed key - resource = await main_app.read_resource("api+config://settings") - assert resource is not None - resource = json.loads(resource) - assert resource["api_key"] == "12345" - assert resource["base_url"] == "https://api.example.com" + async with Client(main_app) as client: + result = await client.read_resource("api+config://settings") + assert isinstance(result[0], TextResourceContents) + config_data = json.loads(result[0].text) + assert config_data["api_key"] == "12345" + assert config_data["base_url"] == "https://api.example.com" -async def test_mount_with_proxy_resource_templates(): +async def test_import_with_proxy_resource_templates(): """ - Test mounting with resource templates that have custom keys. + Test importing with resource templates that have custom keys. This tests that the template's name doesn't change even though the registered key does, which is important for correct instantiation. @@ -418,13 +378,15 @@ async def test_mount_with_proxy_resource_templates(): def create_user(name: str, email: str): return {"name": name, "email": email} - main_app.import_server("api", await FastMCP.as_proxy(api_app)) + proxy_app = FastMCP.from_client(Client(api_app)) + await main_app.import_server("api", proxy_app) # Instantiate the template through the main app with the prefixed key quoted_name = quote("John Doe", safe="") quoted_email = quote("john@example.com", safe="") - user = await main_app.read_resource(f"api+user://{quoted_name}/{quoted_email}") - assert user is not None - user = json.loads(user) - assert user["name"] == "John Doe" - assert user["email"] == "john@example.com" + async with Client(main_app) as client: + result = await client.read_resource(f"api+user://{quoted_name}/{quoted_email}") + assert isinstance(result[0], TextResourceContents) + user_data = json.loads(result[0].text) + assert user_data["name"] == "John Doe" + assert user_data["email"] == "john@example.com" diff --git a/tests/server/test_lifespan.py b/tests/server/test_lifespan.py index 30598d4ad..aa1aef466 100644 --- a/tests/server/test_lifespan.py +++ b/tests/server/test_lifespan.py @@ -4,7 +4,6 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager import anyio -import pytest from mcp.types import ( ClientCapabilities, Implementation, @@ -18,7 +17,6 @@ from pydantic import TypeAdapter from fastmcp import Context, FastMCP -@pytest.mark.anyio async def test_fastmcp_server_lifespan(): """Test that lifespan works in FastMCP server.""" diff --git a/tests/server/test_openapi.py b/tests/server/test_openapi.py index 160c29d6c..afad3b669 100644 --- a/tests/server/test_openapi.py +++ b/tests/server/test_openapi.py @@ -1,3 +1,4 @@ +import json import re import httpx @@ -5,10 +6,12 @@ import pytest from dirty_equals import IsStr from fastapi import FastAPI, HTTPException from httpx import ASGITransport, AsyncClient +from mcp.types import TextContent from pydantic import BaseModel, TypeAdapter from pydantic.networks import AnyUrl from fastmcp import FastMCP +from fastmcp.client import Client from fastmcp.server.openapi import FastMCPOpenAPI @@ -153,13 +156,19 @@ class TestTools: """ The tool created by the OpenAPI server should be the same as the original """ - tool_response = await fastmcp_openapi_server.call_tool( + tool_response = await fastmcp_openapi_server._mcp_call_tool( "create_user_users_post", {"name": "David", "active": False} ) - assert tool_response == User(id=4, name="David", active=False) + + # Convert TextContent to dict for comparison + assert isinstance(tool_response, list) and len(tool_response) == 1 + assert isinstance(tool_response[0], TextContent) + + response_data = json.loads(tool_response[0].text) + expected_user = User(id=4, name="David", active=False).model_dump() + assert response_data == expected_user # Check that the user was created via API - response = await api_client.get("/users") assert len(response.json()) == 4 @@ -168,7 +177,7 @@ class TestTools: "resource://openapi/get_user_users__user_id__get/4" ) user = user_response[0].content - assert user == tool_response.model_dump() + assert user == expected_user async def test_call_update_user_name_tool( self, fastmcp_openapi_server: FastMCPOpenAPI, api_client @@ -176,21 +185,28 @@ class TestTools: """ The tool created by the OpenAPI server should be the same as the original """ - tool_response = await fastmcp_openapi_server.call_tool( + tool_response = await fastmcp_openapi_server._mcp_call_tool( "update_user_name_users__user_id__name_patch", {"user_id": 1, "name": "XYZ"} ) - assert tool_response == dict(id=1, name="XYZ", active=True) + + # Convert TextContent to dict for comparison + assert isinstance(tool_response, list) and len(tool_response) == 1 + assert isinstance(tool_response[0], TextContent) + + response_data = json.loads(tool_response[0].text) + expected_data = dict(id=1, name="XYZ", active=True) + assert response_data == expected_data # Check that the user was updated via API response = await api_client.get("/users") - assert dict(id=1, name="XYZ", active=True) in response.json() + assert expected_data in response.json() # Check that the user was updated via MCP user_response = await fastmcp_openapi_server._mcp_read_resource( "resource://openapi/get_user_users__user_id__get/1" ) user = user_response[0].content - assert user == tool_response + assert user == expected_data class TestResources: @@ -308,7 +324,9 @@ class TestTagTransfer: ): """Test that tags from OpenAPI routes are correctly transferred to Resources.""" # Get internal resources directly - resources = fastmcp_openapi_server._resource_manager.list_resources() + resources = list( + fastmcp_openapi_server._resource_manager.get_resources().values() + ) # Find the get_users resource get_users_resource = next( @@ -327,7 +345,9 @@ class TestTagTransfer: ): """Test that tags from OpenAPI routes are correctly transferred to ResourceTemplates.""" # Get internal resource templates directly - templates = fastmcp_openapi_server._resource_manager.list_templates() + templates = list( + fastmcp_openapi_server._resource_manager.get_templates().values() + ) # Find the get_user template get_user_template = next( @@ -346,7 +366,9 @@ class TestTagTransfer: ): """Test that tags are preserved when creating resources from templates.""" # Get internal resource templates directly - templates = fastmcp_openapi_server._resource_manager.list_templates() + templates = list( + fastmcp_openapi_server._resource_manager.get_templates().values() + ) # Find the get_user template get_user_template = next( @@ -513,12 +535,17 @@ class TestOpenAPI30Compatibility: async def test_tool_execution(self, openapi_30_server): """Test executing a tool from an OpenAPI 3.0 server.""" - tool_response = await openapi_30_server.call_tool( - "createProduct", {"name": "New Product", "price": 39.99} - ) - assert tool_response["id"] == "p3" - assert tool_response["name"] == "New Product" - assert tool_response["price"] == 39.99 + async with Client(openapi_30_server) as client: + result = await client.call_tool( + "createProduct", {"name": "New Product", "price": 39.99} + ) + # Result should be a text content + assert len(result) == 1 + assert isinstance(result[0], TextContent) + product = json.loads(result[0].text) + assert product["id"] == "p3" + assert product["name"] == "New Product" + assert product["price"] == 39.99 class TestOpenAPI31Compatibility: @@ -679,12 +706,17 @@ class TestOpenAPI31Compatibility: async def test_tool_execution(self, openapi_31_server): """Test executing a tool from an OpenAPI 3.1 server.""" - tool_response = await openapi_31_server.call_tool( - "createOrder", {"customer": "Charlie", "items": ["item4", "item5"]} - ) - assert tool_response["id"] == "o3" - assert tool_response["customer"] == "Charlie" - assert tool_response["items"] == ["item4", "item5"] + async with Client(openapi_31_server) as client: + result = await client.call_tool( + "createOrder", {"customer": "Charlie", "items": ["item4", "item5"]} + ) + # Result should be a text content + assert len(result) == 1 + assert isinstance(result[0], TextContent) + order = json.loads(result[0].text) + assert order["id"] == "o3" + assert order["customer"] == "Charlie" + assert order["items"] == ["item4", "item5"] class TestMountFastMCP: @@ -694,7 +726,7 @@ class TestMountFastMCP: """Test mounting an OpenAPI server.""" mcp = FastMCP("MainApp") - mcp.import_server("fastapi", fastmcp_openapi_server) + await mcp.import_server("fastapi", fastmcp_openapi_server) # Check that resources are available with prefixed URIs resources = await mcp._mcp_list_resources() diff --git a/tests/server/test_proxy.py b/tests/server/test_proxy.py index 75e9b7a1f..882977527 100644 --- a/tests/server/test_proxy.py +++ b/tests/server/test_proxy.py @@ -1,13 +1,14 @@ import json from typing import Any +import mcp.types import pytest from dirty_equals import Contains from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.client.transports import FastMCPTransport -from fastmcp.exceptions import ResourceError +from fastmcp.exceptions import ClientError from fastmcp.server.proxy import FastMCPProxy USERS = [ @@ -64,7 +65,7 @@ def fastmcp_server(): @pytest.fixture async def proxy_server(fastmcp_server): """Fixture that creates a FastMCP proxy server.""" - return await FastMCP.as_proxy(Client(transport=FastMCPTransport(fastmcp_server))) + return FastMCP.from_client(Client(transport=FastMCPTransport(fastmcp_server))) async def test_create_proxy(fastmcp_server): @@ -72,7 +73,7 @@ async def test_create_proxy(fastmcp_server): # Create a client client = Client(transport=FastMCPTransport(fastmcp_server)) - server = await FastMCPProxy.from_client(client) + server = FastMCPProxy.from_client(client) assert isinstance(server, FastMCPProxy) assert isinstance(server, FastMCP) @@ -81,7 +82,7 @@ async def test_create_proxy(fastmcp_server): class TestTools: async def test_get_tools(self, proxy_server): - tools = proxy_server.get_tools() + tools = await proxy_server.get_tools() assert "greet" in tools assert "add" in tools assert "error_tool" in tools @@ -95,25 +96,28 @@ class TestTools: 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"}) + result = await fastmcp_server._mcp_call_tool("greet", {"name": "Alice"}) + proxy_result = await proxy_server._mcp_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}) + async with Client(proxy_server) as client: + proxy_result = await client.call_tool("add", {"a": 1, "b": 2}) + assert isinstance(proxy_result[0], mcp.types.TextContent) 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", {}) + with pytest.raises(ClientError, match=""): + async with Client(proxy_server) as client: + await client.call_tool("error_tool", {}) class TestResources: - async def test_list_resources(self, proxy_server): - resources = proxy_server.list_resources() - assert [r.name for r in resources] == Contains( + async def test_get_resources(self, proxy_server): + resources = await proxy_server.get_resources() + assert [r.name for r in resources.values()] == Contains( "data://users", "resource://wave" ) @@ -124,29 +128,36 @@ class TestResources: ) async def test_read_resource(self, proxy_server: FastMCPProxy): - result = await proxy_server.read_resource("resource://wave") - assert result == "👋" + async with Client(proxy_server) as client: + result = await client.read_resource("resource://wave") + assert isinstance(result[0], mcp.types.TextResourceContents) + assert result[0].text == "👋" 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") + async with Client(fastmcp_server) as client: + result = await client.read_resource("resource://wave") + async with Client(proxy_server) as client: + proxy_result = await client.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) == USERS + async with Client(proxy_server) as client: + result = await client.read_resource("data://users") + assert isinstance(result[0], mcp.types.TextResourceContents) + assert json.loads(result[0].text) == USERS async def test_read_resource_returns_none_if_not_found(self, proxy_server): with pytest.raises( - ResourceError, match="Unknown resource: resource://nonexistent" + ClientError, match="Unknown resource: resource://nonexistent" ): - await proxy_server.read_resource("resource://nonexistent") + async with Client(proxy_server) as client: + await client.read_resource("resource://nonexistent") class TestResourceTemplates: - async def test_list_resource_templates(self, proxy_server): - templates = proxy_server.list_resource_templates() - assert [t.name for t in templates] == Contains("get_user") + async def test_get_resource_templates(self, proxy_server): + templates = await proxy_server.get_resource_templates() + assert [t.name for t in templates.values()] == Contains("get_user") async def test_list_resource_templates_same_as_original( self, fastmcp_server, proxy_server @@ -157,35 +168,46 @@ class TestResourceTemplates: @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) == USERS[id - 1] + async with Client(proxy_server) as client: + result = await client.read_resource(f"data://user/{id}") + assert isinstance(result[0], mcp.types.TextResourceContents) + assert json.loads(result[0].text) == USERS[id - 1] 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") + async with Client(fastmcp_server) as client: + result = await client.read_resource("data://user/1") + async with Client(proxy_server) as client: + proxy_result = await client.read_resource("data://user/1") assert proxy_result == result class TestPrompts: - async def test_list_prompts(self, proxy_server): - prompts = proxy_server.list_prompts() - assert [p.name for p in prompts] == Contains("welcome") + async def test_get_prompts_server_method(self, proxy_server: FastMCPProxy): + prompts = await proxy_server.get_prompts() + assert [p.name for p in prompts.values()] == Contains("welcome") async def test_list_prompts_same_as_original(self, fastmcp_server, proxy_server): - assert ( - await proxy_server._mcp_list_prompts() - == await fastmcp_server._mcp_list_prompts() - ) + async with Client(fastmcp_server) as client: + result = await client.list_prompts() + async with Client(proxy_server) as client: + proxy_result = await client.list_prompts() + assert proxy_result == result async def test_render_prompt_same_as_original( - self, fastmcp_server: FastMCP, proxy_server + self, fastmcp_server: FastMCP, proxy_server: FastMCPProxy ): - result = await fastmcp_server.get_prompt("welcome", {"name": "Alice"}) - proxy_result = await proxy_server.get_prompt("welcome", {"name": "Alice"}) + async with Client(fastmcp_server) as client: + result = await client.get_prompt("welcome", {"name": "Alice"}) + async with Client(proxy_server) as client: + proxy_result = await client.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"}) + async with Client(proxy_server) as client: + result = await client.get_prompt("welcome", {"name": "Alice"}) + assert isinstance(result[0], mcp.types.PromptMessage) + assert result[0].role == "user" + assert isinstance(result[0].content, mcp.types.TextContent) assert result[0].content.text == "Welcome to FastMCP, Alice!" diff --git a/tests/server/test_run_server.py b/tests/server/test_run_server.py index fb936b578..5109a27bc 100644 --- a/tests/server/test_run_server.py +++ b/tests/server/test_run_server.py @@ -93,6 +93,6 @@ # class TestRunServerSSE: -# @pytest.mark.anyio +# # async def test_run_server_sse(self, fastmcp_server: FastMCP): # pass diff --git a/tests/server/test_server.py b/tests/server/test_server.py index c5cef1fee..42a47ae65 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -4,7 +4,6 @@ from pathlib import Path from typing import TYPE_CHECKING import pytest -from mcp.shared.exceptions import McpError from mcp.types import ( BlobResourceContents, ImageContent, @@ -14,7 +13,7 @@ from mcp.types import ( from pydantic import AnyUrl, Field from fastmcp import Client, Context, FastMCP -from fastmcp.exceptions import ResourceError, ToolError +from fastmcp.exceptions import ClientError, NotFoundError, ToolError from fastmcp.prompts.prompt import EmbeddedResource, Message, UserMessage from fastmcp.resources import FileResource, FunctionResource from fastmcp.utilities.types import Image @@ -89,8 +88,8 @@ class TestToolDecorator: async def test_no_tools_before_decorator(self): mcp = FastMCP() - with pytest.raises(ToolError, match="Unknown tool: add"): - await mcp.call_tool("add", {"x": 1, "y": 2}) + with pytest.raises(NotFoundError, match="Unknown tool: add"): + await mcp._mcp_call_tool("add", {"x": 1, "y": 2}) async def test_tool_decorator(self): mcp = FastMCP() @@ -99,7 +98,7 @@ class TestToolDecorator: def add(x: int, y: int) -> int: return x + y - result = await mcp.call_tool("add", {"x": 1, "y": 2}) + result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2}) assert isinstance(result[0], TextContent) assert result[0].text == "3" @@ -119,7 +118,7 @@ class TestToolDecorator: def add(x: int, y: int) -> int: return x + y - result = await mcp.call_tool("custom-add", {"x": 1, "y": 2}) + result = await mcp._mcp_call_tool("custom-add", {"x": 1, "y": 2}) assert isinstance(result[0], TextContent) assert result[0].text == "3" @@ -148,7 +147,7 @@ class TestToolDecorator: obj = MyClass(10) mcp.add_tool(obj.add) - result = await mcp.call_tool("add", {"y": 2}) + result = await mcp._mcp_call_tool("add", {"y": 2}) assert isinstance(result[0], TextContent) assert result[0].text == "12" @@ -163,7 +162,7 @@ class TestToolDecorator: return cls.x + y mcp.add_tool(MyClass.add) - result = await mcp.call_tool("add", {"y": 2}) + result = await mcp._mcp_call_tool("add", {"y": 2}) assert isinstance(result[0], TextContent) assert result[0].text == "12" @@ -176,7 +175,7 @@ class TestToolDecorator: def add(x: int, y: int) -> int: return x + y - result = await mcp.call_tool("add", {"x": 1, "y": 2}) + result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2}) assert isinstance(result[0], TextContent) assert result[0].text == "3" @@ -187,7 +186,7 @@ class TestToolDecorator: async def add(x: int, y: int) -> int: return x + y - result = await mcp.call_tool("add", {"x": 1, "y": 2}) + result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2}) assert isinstance(result[0], TextContent) assert result[0].text == "3" @@ -202,7 +201,7 @@ class TestToolDecorator: return cls.x + y mcp.add_tool(MyClass.add) - result = await mcp.call_tool("add", {"y": 2}) + result = await mcp._mcp_call_tool("add", {"y": 2}) assert isinstance(result[0], TextContent) assert result[0].text == "12" @@ -215,7 +214,7 @@ class TestToolDecorator: return x + y mcp.add_tool(MyClass.add) - result = await mcp.call_tool("add", {"x": 1, "y": 2}) + result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2}) assert isinstance(result[0], TextContent) assert result[0].text == "3" @@ -240,15 +239,14 @@ class TestToolDecorator: """Multiply two numbers.""" return a * b - # Add the tool with a custom name mcp.add_tool(multiply, name="custom_multiply") # Check that the tool is registered with the custom name - tools = mcp.get_tools() + tools = await mcp.get_tools() assert "custom_multiply" in tools # Call the tool by its custom name - result = await mcp.call_tool("custom_multiply", {"a": 5, "b": 3}) + result = await mcp._mcp_call_tool("custom_multiply", {"a": 5, "b": 3}) assert isinstance(result[0], TextContent) assert result[0].text == "15" @@ -260,8 +258,9 @@ class TestResourceDecorator: async def test_no_resources_before_decorator(self): mcp = FastMCP() - with pytest.raises(ResourceError, match="Unknown resource"): - await mcp.read_resource("resource://data") + with pytest.raises(ClientError, match="Unknown resource"): + async with Client(mcp) as client: + await client.read_resource("resource://data") async def test_resource_decorator(self): mcp = FastMCP() @@ -270,8 +269,10 @@ class TestResourceDecorator: def get_data() -> str: return "Hello, world!" - result = await mcp.read_resource("resource://data") - assert result == "Hello, world!" + async with Client(mcp) as client: + result = await client.read_resource("resource://data") + assert isinstance(result[0], TextResourceContents) + assert result[0].text == "Hello, world!" async def test_resource_decorator_incorrect_usage(self): mcp = FastMCP() @@ -291,12 +292,15 @@ class TestResourceDecorator: def get_data() -> str: return "Hello, world!" - resources = mcp.list_resources() + resources_dict = await mcp.get_resources() + resources = list(resources_dict.values()) assert len(resources) == 1 assert resources[0].name == "custom-data" - result = await mcp.read_resource("resource://data") - assert result == "Hello, world!" + async with Client(mcp) as client: + result = await client.read_resource("resource://data") + assert isinstance(result[0], TextResourceContents) + assert result[0].text == "Hello, world!" async def test_resource_decorator_with_description(self): mcp = FastMCP() @@ -305,10 +309,24 @@ class TestResourceDecorator: def get_data() -> str: return "Hello, world!" - resources = mcp.list_resources() + resources_dict = await mcp.get_resources() + resources = list(resources_dict.values()) assert len(resources) == 1 assert resources[0].description == "Data resource" + async def test_resource_decorator_with_tags(self): + """Test that the resource decorator properly sets tags.""" + mcp = FastMCP() + + @mcp.resource("resource://data", tags={"example", "test-tag"}) + def get_data() -> str: + return "Hello, world!" + + resources_dict = await mcp.get_resources() + resources = list(resources_dict.values()) + assert len(resources) == 1 + assert resources[0].tags == {"example", "test-tag"} + async def test_resource_decorator_instance_method(self): mcp = FastMCP() @@ -324,8 +342,10 @@ class TestResourceDecorator: obj.get_data, uri="resource://data", name="instance-resource" ) - result = await mcp.read_resource("resource://data") - assert result == "My prefix: Hello, world!" + async with Client(mcp) as client: + result = await client.read_resource("resource://data") + assert isinstance(result[0], TextResourceContents) + assert result[0].text == "My prefix: Hello, world!" async def test_resource_decorator_classmethod(self): mcp = FastMCP() @@ -341,8 +361,10 @@ class TestResourceDecorator: MyClass.get_data, uri="resource://data", name="class-resource" ) - result = await mcp.read_resource("resource://data") - assert result == "Class prefix: Hello, world!" + async with Client(mcp) as client: + result = await client.read_resource("resource://data") + assert isinstance(result[0], TextResourceContents) + assert result[0].text == "Class prefix: Hello, world!" async def test_resource_decorator_staticmethod(self): mcp = FastMCP() @@ -353,8 +375,10 @@ class TestResourceDecorator: def get_data() -> str: return "Static Hello, world!" - result = await mcp.read_resource("resource://data") - assert result == "Static Hello, world!" + async with Client(mcp) as client: + result = await client.read_resource("resource://data") + assert isinstance(result[0], TextResourceContents) + assert result[0].text == "Static Hello, world!" async def test_resource_decorator_async_function(self): mcp = FastMCP() @@ -363,19 +387,10 @@ class TestResourceDecorator: async def get_data() -> str: return "Async Hello, world!" - result = await mcp.read_resource("resource://data") - assert result == "Async Hello, world!" - - async def test_resource_decorator_with_tags(self): - mcp = FastMCP() - - @mcp.resource("resource://data", tags={"example", "test-tag"}) - def get_data() -> str: - return "Hello, world!" - - resources = mcp.list_resources() - assert len(resources) == 1 - assert resources[0].tags == {"example", "test-tag"} + async with Client(mcp) as client: + result = await client.read_resource("resource://data") + assert isinstance(result[0], TextResourceContents) + assert result[0].text == "Async Hello, world!" class TestTemplateDecorator: @@ -386,12 +401,16 @@ class TestTemplateDecorator: def get_data(name: str) -> str: return f"Data for {name}" - templates = mcp.list_resource_templates() + templates_dict = await mcp.get_resource_templates() + templates = list(templates_dict.values()) assert len(templates) == 1 + assert templates[0].name == "get_data" assert templates[0].uri_template == "resource://{name}/data" - result = await mcp.read_resource("resource://test/data") - assert result == "Data for test" + async with Client(mcp) as client: + result = await client.read_resource("resource://test/data") + assert isinstance(result[0], TextResourceContents) + assert result[0].text == "Data for test" async def test_template_decorator_incorrect_usage(self): mcp = FastMCP() @@ -411,12 +430,15 @@ class TestTemplateDecorator: def get_data(name: str) -> str: return f"Data for {name}" - templates = mcp.list_resource_templates() + templates_dict = await mcp.get_resource_templates() + templates = list(templates_dict.values()) assert len(templates) == 1 assert templates[0].name == "custom-template" - result = await mcp.read_resource("resource://test/data") - assert result == "Data for test" + async with Client(mcp) as client: + result = await client.read_resource("resource://test/data") + assert isinstance(result[0], TextResourceContents) + assert result[0].text == "Data for test" async def test_template_decorator_with_description(self): mcp = FastMCP() @@ -425,7 +447,8 @@ class TestTemplateDecorator: def get_data(name: str) -> str: return f"Data for {name}" - templates = mcp.list_resource_templates() + templates_dict = await mcp.get_resource_templates() + templates = list(templates_dict.values()) assert len(templates) == 1 assert templates[0].description == "Template description" @@ -440,13 +463,14 @@ class TestTemplateDecorator: return f"{self.prefix} Data for {name}" obj = MyClass("My prefix:") - mcp.add_resource_fn( obj.get_data, uri="resource://{name}/data", name="instance-template" ) - result = await mcp.read_resource("resource://test/data") - assert result == "My prefix: Data for test" + async with Client(mcp) as client: + result = await client.read_resource("resource://test/data") + assert isinstance(result[0], TextResourceContents) + assert result[0].text == "My prefix: Data for test" async def test_template_decorator_classmethod(self): mcp = FastMCP() @@ -459,11 +483,15 @@ class TestTemplateDecorator: return f"{cls.prefix} Data for {name}" mcp.add_resource_fn( - MyClass.get_data, uri="resource://{name}/data", name="class-template" + MyClass.get_data, + uri="resource://{name}/data", + name="class-template", ) - result = await mcp.read_resource("resource://test/data") - assert result == "Class prefix: Data for test" + async with Client(mcp) as client: + result = await client.read_resource("resource://test/data") + assert isinstance(result[0], TextResourceContents) + assert result[0].text == "Class prefix: Data for test" async def test_template_decorator_staticmethod(self): mcp = FastMCP() @@ -474,8 +502,10 @@ class TestTemplateDecorator: def get_data(name: str) -> str: return f"Static Data for {name}" - result = await mcp.read_resource("resource://test/data") - assert result == "Static Data for test" + async with Client(mcp) as client: + result = await client.read_resource("resource://test/data") + assert isinstance(result[0], TextResourceContents) + assert result[0].text == "Static Data for test" async def test_template_decorator_async_function(self): mcp = FastMCP() @@ -484,19 +514,22 @@ class TestTemplateDecorator: async def get_data(name: str) -> str: return f"Async Data for {name}" - result = await mcp.read_resource("resource://test/data") - assert result == "Async Data for test" + async with Client(mcp) as client: + result = await client.read_resource("resource://test/data") + assert isinstance(result[0], TextResourceContents) + assert result[0].text == "Async Data for test" async def test_template_decorator_with_tags(self): + """Test that the template decorator properly sets tags.""" mcp = FastMCP() - @mcp.resource("resource://{name}/data", tags={"template", "test-tag"}) - def get_data(name: str) -> str: - return f"Data for {name}" + @mcp.resource("resource://{param}", tags={"template", "test-tag"}) + def template_resource(param: str) -> str: + return f"Template resource: {param}" - templates = mcp.list_resource_templates() - assert len(templates) == 1 - assert templates[0].tags == {"template", "test-tag"} + templates_dict = await mcp.get_resource_templates() + template = templates_dict["resource://{param}"] + assert template.tags == {"template", "test-tag"} class TestPromptDecorator: @@ -504,18 +537,17 @@ class TestPromptDecorator: mcp = FastMCP() @mcp.prompt() - def test_prompt() -> str: + def fn() -> str: return "Hello, world!" - prompts = mcp.list_prompts() - assert len(prompts) == 1 - assert prompts[0].name == "test_prompt" - - result = await mcp.get_prompt("test_prompt") - assert len(result) == 1 - message = result[0] - assert isinstance(message.content, TextContent) - assert message.content.text == "Hello, world!" + prompts_dict = await mcp.get_prompts() + assert len(prompts_dict) == 1 + prompt = prompts_dict["fn"] + assert prompt.name == "fn" + # Don't compare functions directly since validate_call wraps them + content = await prompt.render() + assert isinstance(content[0].content, TextContent) + assert content[0].content.text == "Hello, world!" async def test_prompt_decorator_incorrect_usage(self): mcp = FastMCP() @@ -525,36 +557,38 @@ class TestPromptDecorator: ): @mcp.prompt # Missing parentheses #type: ignore - def test_prompt() -> str: + def fn() -> str: return "Hello, world!" async def test_prompt_decorator_with_name(self): mcp = FastMCP() - @mcp.prompt(name="custom-prompt") - def test_prompt() -> str: + @mcp.prompt(name="custom_name") + def fn() -> str: return "Hello, world!" - prompts = mcp.list_prompts() - assert len(prompts) == 1 - assert prompts[0].name == "custom-prompt" - - result = await mcp.get_prompt("custom-prompt") - assert len(result) == 1 - message = result[0] - assert isinstance(message.content, TextContent) - assert message.content.text == "Hello, world!" + prompts_dict = await mcp.get_prompts() + assert len(prompts_dict) == 1 + prompt = prompts_dict["custom_name"] + assert prompt.name == "custom_name" + content = await prompt.render() + assert isinstance(content[0].content, TextContent) + assert content[0].content.text == "Hello, world!" async def test_prompt_decorator_with_description(self): mcp = FastMCP() - @mcp.prompt(description="Test prompt description") - def test_prompt() -> str: + @mcp.prompt(description="A custom description") + def fn() -> str: return "Hello, world!" - prompts = mcp.list_prompts() - assert len(prompts) == 1 - assert prompts[0].description == "Test prompt description" + prompts_dict = await mcp.get_prompts() + assert len(prompts_dict) == 1 + prompt = prompts_dict["fn"] + assert prompt.description == "A custom description" + content = await prompt.render() + assert isinstance(content[0].content, TextContent) + assert content[0].content.text == "Hello, world!" async def test_prompt_decorator_with_parameters(self): mcp = FastMCP() @@ -563,28 +597,30 @@ class TestPromptDecorator: def test_prompt(name: str, greeting: str = "Hello") -> str: return f"{greeting}, {name}!" - prompts = mcp.list_prompts() - assert len(prompts) == 1 - assert prompts[0].arguments is not None - assert len(prompts[0].arguments) == 2 - assert prompts[0].arguments[0].name == "name" - assert prompts[0].arguments[0].required is True - assert prompts[0].arguments[1].name == "greeting" - assert prompts[0].arguments[1].required is False + prompts_dict = await mcp.get_prompts() + assert len(prompts_dict) == 1 + prompt = prompts_dict["test_prompt"] + assert prompt.arguments is not None + assert len(prompt.arguments) == 2 + assert prompt.arguments[0].name == "name" + assert prompt.arguments[0].required is True + assert prompt.arguments[1].name == "greeting" + assert prompt.arguments[1].required is False - result = await mcp.get_prompt("test_prompt", {"name": "World"}) - assert len(result) == 1 - message = result[0] - assert isinstance(message.content, TextContent) - assert message.content.text == "Hello, World!" + async with Client(mcp) as client: + result = await client.get_prompt("test_prompt", {"name": "World"}) + assert len(result) == 1 + message = result[0] + assert isinstance(message.content, TextContent) + assert message.content.text == "Hello, World!" - result = await mcp.get_prompt( - "test_prompt", {"name": "World", "greeting": "Hi"} - ) - assert len(result) == 1 - message = result[0] - assert isinstance(message.content, TextContent) - assert message.content.text == "Hi, World!" + result = await client.get_prompt( + "test_prompt", {"name": "World", "greeting": "Hi"} + ) + assert len(result) == 1 + message = result[0] + assert isinstance(message.content, TextContent) + assert message.content.text == "Hi, World!" async def test_prompt_decorator_instance_method(self): mcp = FastMCP() @@ -599,11 +635,12 @@ class TestPromptDecorator: obj = MyClass("My prefix:") mcp.add_prompt(obj.test_prompt, name="test_prompt") - result = await mcp.get_prompt("test_prompt") - assert len(result) == 1 - message = result[0] - assert isinstance(message.content, TextContent) - assert message.content.text == "My prefix: Hello, world!" + async with Client(mcp) as client: + result = await client.get_prompt("test_prompt") + assert len(result) == 1 + message = result[0] + assert isinstance(message.content, TextContent) + assert message.content.text == "My prefix: Hello, world!" async def test_prompt_decorator_classmethod(self): mcp = FastMCP() @@ -617,11 +654,12 @@ class TestPromptDecorator: mcp.add_prompt(MyClass.test_prompt, name="test_prompt") - result = await mcp.get_prompt("test_prompt") - assert len(result) == 1 - message = result[0] - assert isinstance(message.content, TextContent) - assert message.content.text == "Class prefix: Hello, world!" + async with Client(mcp) as client: + result = await client.get_prompt("test_prompt") + assert len(result) == 1 + message = result[0] + assert isinstance(message.content, TextContent) + assert message.content.text == "Class prefix: Hello, world!" async def test_prompt_decorator_staticmethod(self): mcp = FastMCP() @@ -632,11 +670,12 @@ class TestPromptDecorator: def test_prompt() -> str: return "Static Hello, world!" - result = await mcp.get_prompt("test_prompt") - assert len(result) == 1 - message = result[0] - assert isinstance(message.content, TextContent) - assert message.content.text == "Static Hello, world!" + async with Client(mcp) as client: + result = await client.get_prompt("test_prompt") + assert len(result) == 1 + message = result[0] + assert isinstance(message.content, TextContent) + assert message.content.text == "Static Hello, world!" async def test_prompt_decorator_async_function(self): mcp = FastMCP() @@ -645,22 +684,25 @@ class TestPromptDecorator: async def test_prompt() -> str: return "Async Hello, world!" - result = await mcp.get_prompt("test_prompt") - assert len(result) == 1 - message = result[0] - assert isinstance(message.content, TextContent) - assert message.content.text == "Async Hello, world!" + async with Client(mcp) as client: + result = await client.get_prompt("test_prompt") + assert len(result) == 1 + message = result[0] + assert isinstance(message.content, TextContent) + assert message.content.text == "Async Hello, world!" async def test_prompt_decorator_with_tags(self): + """Test that the prompt decorator properly sets tags.""" mcp = FastMCP() @mcp.prompt(tags={"example", "test-tag"}) - def test_prompt() -> str: + def sample_prompt() -> str: return "Hello, world!" - prompts = mcp.list_prompts() - assert len(prompts) == 1 - assert prompts[0].tags == {"example", "test-tag"} + prompts_dict = await mcp.get_prompts() + assert len(prompts_dict) == 1 + prompt = prompts_dict["sample_prompt"] + assert prompt.tags == {"example", "test-tag"} @pytest.fixture @@ -710,7 +752,7 @@ class TestServerTools: assert len(await tool_server._mcp_list_tools()) == 6 async def test_call_tool(self, tool_server: FastMCP): - result = await tool_server.call_tool("add", {"x": 1, "y": 2}) + result = await tool_server._mcp_call_tool("add", {"x": 1, "y": 2}) assert isinstance(result[0], TextContent) assert result[0].text == "3" @@ -722,7 +764,7 @@ class TestServerTools: async def test_call_tool_error(self, tool_server: FastMCP): with pytest.raises(ToolError): - await tool_server.call_tool("error_tool", {}) + await tool_server._mcp_call_tool("error_tool", {}) async def test_call_tool_error_as_client(self, tool_server: FastMCP): async with Client(tool_server) as client: @@ -737,7 +779,7 @@ class TestServerTools: assert "Test error" in result.content[0].text async def test_tool_returns_list(self, tool_server: FastMCP): - result = await tool_server.call_tool("list_tool", {}) + result = await tool_server._mcp_call_tool("list_tool", {}) assert isinstance(result[0], TextContent) assert result[0].text == '["x", 2]' @@ -746,7 +788,9 @@ class TestServerTools: image_path = tmp_path / "test.png" image_path.write_bytes(b"fake png data") - result = await tool_server.call_tool("image_tool", {"path": str(image_path)}) + result = await tool_server._mcp_call_tool( + "image_tool", {"path": str(image_path)} + ) content = result[0] assert isinstance(content, ImageContent) assert content.type == "image" @@ -756,7 +800,7 @@ class TestServerTools: assert decoded == b"fake png data" async def test_tool_mixed_content(self, tool_server: FastMCP): - result = await tool_server.call_tool("mixed_content_tool", {}) + result = await tool_server._mcp_call_tool("mixed_content_tool", {}) assert len(result) == 2 content1 = result[0] content2 = result[1] @@ -775,7 +819,7 @@ class TestServerTools: image_path = tmp_path / "test.png" image_path.write_bytes(b"test image data") - result = await tool_server.call_tool( + result = await tool_server._mcp_call_tool( "mixed_list_fn", {"image_path": str(image_path)} ) assert len(result) == 3 @@ -993,8 +1037,7 @@ class TestServerResourceTemplates: assert result[0].text == "Static data" async def test_template_with_default_params(self): - """Test that a template with default function parameters works when those parameters - are not in the URI template""" + """Test that a template can have default parameters.""" mcp = FastMCP() @mcp.resource("math://add/{x}") @@ -1002,7 +1045,8 @@ class TestServerResourceTemplates: return x + y # Verify it's registered as a template - templates = mcp.list_resource_templates() + templates_dict = await mcp.get_resource_templates() + templates = list(templates_dict.values()) assert len(templates) == 1 assert templates[0].uri_template == "math://add/{x}" @@ -1019,16 +1063,18 @@ class TestServerResourceTemplates: assert result == "17" # 7 + default 10 async def test_template_to_resource_conversion(self): - """Test that templates are properly converted to resources when accessed""" + """Test that a template can be converted to a resource.""" mcp = FastMCP() @mcp.resource("resource://{name}/data") def get_data(name: str) -> str: return f"Data for {name}" - # Should be registered as a template - assert len(mcp._resource_manager._templates) == 1 - assert len(await mcp._mcp_list_resources()) == 0 + # Verify it's registered as a template + templates_dict = await mcp.get_resource_templates() + templates = list(templates_dict.values()) + assert len(templates) == 1 + assert templates[0].uri_template == "resource://{name}/data" # When accessed, should create a concrete resource resource = await mcp._resource_manager.get_resource("resource://test/data") @@ -1037,30 +1083,32 @@ class TestServerResourceTemplates: assert result == "Data for test" async def test_stacked_resource_template_decorators(self): - """Test that multiple resource decorators can be stacked on the same function.""" + """Test that resource template decorators can be stacked.""" mcp = FastMCP() - # Define a function with multiple stacked resource decorators @mcp.resource("users://email/{email}") @mcp.resource("users://name/{name}") def lookup_user(name: str | None = None, email: str | None = None) -> dict: - """Look up a user by either name or email.""" - # In a real implementation, this would query a database - if email: + if name: return { - "found_by": "email", - "name": f"User for {email}", + "id": "123", + "name": name, + "email": "dummy@example.com", + "lookup": "name", + } + elif email: + return { + "id": "123", + "name": "Test User", "email": email, + "lookup": "email", } else: - return { - "found_by": "name", - "name": name, - "email": f"{name.lower()}@example.com" if name else None, - } + raise ValueError("Either name or email must be provided") # Verify both templates are registered - templates = mcp.list_resource_templates() + templates_dict = await mcp.get_resource_templates() + templates = list(templates_dict.values()) assert len(templates) == 2 template_uris = {t.uri_template for t in templates} assert "users://email/{email}" in template_uris @@ -1073,16 +1121,27 @@ class TestServerResourceTemplates: ) assert isinstance(email_result[0], TextResourceContents) email_data = json.loads(email_result[0].text) - assert email_data["found_by"] == "email" + assert email_data["lookup"] == "email" assert email_data["email"] == "user@example.com" # Test lookup by name name_result = await client.read_resource(AnyUrl("users://name/John")) assert isinstance(name_result[0], TextResourceContents) name_data = json.loads(name_result[0].text) - assert name_data["found_by"] == "name" + assert name_data["lookup"] == "name" assert name_data["name"] == "John" - assert name_data["email"] == "john@example.com" + assert name_data["email"] == "dummy@example.com" + + async def test_template_decorator_with_tags(self): + mcp = FastMCP() + + @mcp.resource("resource://{param}", tags={"template", "test-tag"}) + def template_resource(param: str) -> str: + return f"Template resource: {param}" + + templates_dict = await mcp.get_resource_templates() + template = templates_dict["resource://{param}"] + assert template.tags == {"template", "test-tag"} class TestContextInjection: @@ -1219,11 +1278,12 @@ class TestServerPrompts: def fn() -> str: return "Hello, world!" - prompts = mcp._prompt_manager.list_prompts() - assert len(prompts) == 1 - assert prompts[0].name == "fn" + prompts_dict = await mcp.get_prompts() + assert len(prompts_dict) == 1 + prompt = prompts_dict["fn"] + assert prompt.name == "fn" # Don't compare functions directly since validate_call wraps them - content = await prompts[0].render() + content = await prompt.render() assert isinstance(content[0].content, TextContent) assert content[0].content.text == "Hello, world!" @@ -1235,10 +1295,11 @@ class TestServerPrompts: def fn() -> str: return "Hello, world!" - prompts = mcp._prompt_manager.list_prompts() - assert len(prompts) == 1 - assert prompts[0].name == "custom_name" - content = await prompts[0].render() + prompts_dict = await mcp.get_prompts() + assert len(prompts_dict) == 1 + prompt = prompts_dict["custom_name"] + assert prompt.name == "custom_name" + content = await prompt.render() assert isinstance(content[0].content, TextContent) assert content[0].content.text == "Hello, world!" @@ -1250,10 +1311,11 @@ class TestServerPrompts: def fn() -> str: return "Hello, world!" - prompts = mcp._prompt_manager.list_prompts() - assert len(prompts) == 1 - assert prompts[0].description == "A custom description" - content = await prompts[0].render() + prompts_dict = await mcp.get_prompts() + assert len(prompts_dict) == 1 + prompt = prompts_dict["fn"] + assert prompt.description == "A custom description" + content = await prompt.render() assert isinstance(content[0].content, TextContent) assert content[0].content.text == "Hello, world!" @@ -1272,20 +1334,22 @@ class TestServerPrompts: @mcp.prompt() def fn(name: str, optional: str = "default") -> str: - return f"Hello, {name}!" + return f"Hello, {name}! {optional}" + + prompts_dict = await mcp.get_prompts() + assert len(prompts_dict) == 1 async with Client(mcp) as client: - result = await client.list_prompts() - assert result is not None - assert len(result) == 1 - prompt = result[0] - assert prompt.name == "fn" - assert prompt.arguments is not None - assert len(prompt.arguments) == 2 - assert prompt.arguments[0].name == "name" - assert prompt.arguments[0].required is True - assert prompt.arguments[1].name == "optional" - assert prompt.arguments[1].required is False + prompts = await client.list_prompts() + assert len(prompts) == 1 + assert prompts[0].name == "fn" + assert prompts[0].description is None + assert prompts[0].arguments is not None + assert len(prompts[0].arguments) == 2 + assert prompts[0].arguments[0].name == "name" + assert prompts[0].arguments[0].required is True + assert prompts[0].arguments[1].name == "optional" + assert prompts[0].arguments[1].required is False async def test_get_prompt(self): """Test getting a prompt through MCP protocol.""" @@ -1297,8 +1361,8 @@ class TestServerPrompts: async with Client(mcp) as client: result = await client.get_prompt("fn", {"name": "World"}) - assert len(result.messages) == 1 - message = result.messages[0] + assert len(result) == 1 + message = result[0] assert message.role == "user" content = message.content assert isinstance(content, TextContent) @@ -1323,8 +1387,8 @@ class TestServerPrompts: async with Client(mcp) as client: result = await client.get_prompt("fn") - assert result.messages[0].role == "user" - content = result.messages[0].content + assert result[0].role == "user" + content = result[0].content assert isinstance(content, EmbeddedResource) resource = content.resource assert isinstance(resource, TextResourceContents) @@ -1334,8 +1398,8 @@ class TestServerPrompts: async def test_get_unknown_prompt(self): """Test error when getting unknown prompt.""" mcp = FastMCP() - async with Client(mcp) as client: - with pytest.raises(McpError, match="Unknown prompt"): + with pytest.raises(ClientError, match="Unknown prompt"): + async with Client(mcp) as client: await client.get_prompt("unknown") async def test_get_prompt_missing_args(self): @@ -1346,8 +1410,8 @@ class TestServerPrompts: def prompt_fn(name: str) -> str: return f"Hello, {name}!" - async with Client(mcp) as client: - with pytest.raises(McpError, match="Missing required arguments"): + with pytest.raises(ClientError, match="Missing required arguments"): + async with Client(mcp) as client: await client.get_prompt("prompt_fn") async def test_tool_decorator_with_tags(self): @@ -1364,15 +1428,15 @@ class TestServerPrompts: assert tools[0].tags == {"example", "test-tag"} async def test_resource_decorator_with_tags(self): - """Test that the resource decorator properly sets tags.""" + """Test that the resource decorator supports tags.""" mcp = FastMCP() - @mcp.resource("resource://sample", tags={"example", "test-tag"}) - def sample_resource() -> str: - return "Sample resource" + @mcp.resource("resource://data", tags={"example", "test-tag"}) + def get_data() -> str: + return "Hello, world!" - # Verify the tags were set correctly - resources = mcp._resource_manager.list_resources() + resources_dict = await mcp.get_resources() + resources = list(resources_dict.values()) assert len(resources) == 1 assert resources[0].tags == {"example", "test-tag"} @@ -1384,9 +1448,9 @@ class TestServerPrompts: def template_resource(param: str) -> str: return f"Template resource: {param}" - templates = mcp._resource_manager.list_templates() - assert len(templates) == 1 - assert templates[0].tags == {"template", "test-tag"} + templates_dict = await mcp.get_resource_templates() + template = templates_dict["resource://{param}"] + assert template.tags == {"template", "test-tag"} async def test_prompt_decorator_with_tags(self): """Test that the prompt decorator properly sets tags.""" @@ -1394,9 +1458,9 @@ class TestServerPrompts: @mcp.prompt(tags={"example", "test-tag"}) def sample_prompt() -> str: - return "Sample prompt" + return "Hello, world!" - # Verify the tags were set correctly - prompts = mcp._prompt_manager.list_prompts() - assert len(prompts) == 1 - assert prompts[0].tags == {"example", "test-tag"} + prompts_dict = await mcp.get_prompts() + assert len(prompts_dict) == 1 + prompt = prompts_dict["sample_prompt"] + assert prompt.tags == {"example", "test-tag"} diff --git a/tests/tools/test_tool_manager.py b/tests/tools/test_tool_manager.py index 2c3639b56..de86b5192 100644 --- a/tests/tools/test_tool_manager.py +++ b/tests/tools/test_tool_manager.py @@ -4,7 +4,7 @@ import logging import pytest from pydantic import BaseModel -from fastmcp.exceptions import ToolError +from fastmcp.exceptions import NotFoundError, ToolError from fastmcp.tools import ToolManager from fastmcp.tools.tool import Tool @@ -235,23 +235,6 @@ class TestToolTags: assert len(utility_tools) == 2 assert {tool.name for tool in utility_tools} == {"string_tool", "mixed_tool"} - def test_import_tools_preserves_tags(self): - """Test that importing tools preserves their tags.""" - - def tagged_tool(x: int) -> int: - """A tool with tags.""" - return x - - source_manager = ToolManager() - source_manager.add_tool_from_fn(tagged_tool, tags={"test", "example"}) - - target_manager = ToolManager() - target_manager.import_tools(source_manager, "source/") - - imported_tool = target_manager.get_tool("source/tagged_tool") - assert imported_tool is not None - assert imported_tool.tags == {"test", "example"} - class TestCallTools: async def test_call_tool(self): @@ -262,7 +245,13 @@ class TestCallTools: manager = ToolManager() manager.add_tool_from_fn(add) result = await manager.call_tool("add", {"a": 1, "b": 2}) - assert result == 3 + assert isinstance(result, list) + assert len(result) == 1 + from mcp.types import TextContent + + assert isinstance(result[0], TextContent) + assert result[0].text == "3" + assert json.loads(result[0].text) == 3 async def test_call_async_tool(self): async def double(n: int) -> int: @@ -272,7 +261,13 @@ class TestCallTools: manager = ToolManager() manager.add_tool_from_fn(double) result = await manager.call_tool("double", {"n": 5}) - assert result == 10 + assert isinstance(result, list) + assert len(result) == 1 + from mcp.types import TextContent + + assert isinstance(result[0], TextContent) + assert result[0].text == "10" + assert json.loads(result[0].text) == 10 async def test_call_tool_with_default_args(self): def add(a: int, b: int = 1) -> int: @@ -282,7 +277,13 @@ class TestCallTools: manager = ToolManager() manager.add_tool_from_fn(add) result = await manager.call_tool("add", {"a": 1}) - assert result == 2 + assert isinstance(result, list) + assert len(result) == 1 + from mcp.types import TextContent + + assert isinstance(result[0], TextContent) + assert result[0].text == "2" + assert json.loads(result[0].text) == 2 async def test_call_tool_with_missing_args(self): def add(a: int, b: int) -> int: @@ -296,7 +297,7 @@ class TestCallTools: async def test_call_unknown_tool(self): manager = ToolManager() - with pytest.raises(ToolError): + with pytest.raises(NotFoundError, match="Unknown tool: unknown"): await manager.call_tool("unknown", {"a": 1}) async def test_call_tool_with_list_int_input(self): @@ -306,10 +307,21 @@ class TestCallTools: manager = ToolManager() manager.add_tool_from_fn(sum_vals) # Try both with plain list and with JSON list + from mcp.types import TextContent + result = await manager.call_tool("sum_vals", {"vals": "[1, 2, 3]"}) - assert result == 6 + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].text == "6" + assert json.loads(result[0].text) == 6 + result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]}) - assert result == 6 + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].text == "6" + assert json.loads(result[0].text) == 6 async def test_call_tool_with_list_str_or_str_input(self): def concat_strs(vals: list[str] | str) -> str: @@ -317,17 +329,36 @@ class TestCallTools: manager = ToolManager() manager.add_tool_from_fn(concat_strs) + from mcp.types import TextContent + # Try both with plain python object and with JSON list result = await manager.call_tool("concat_strs", {"vals": ["a", "b", "c"]}) - assert result == "abc" + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].text == "abc" + result = await manager.call_tool("concat_strs", {"vals": '["a", "b", "c"]'}) - assert result == "abc" + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].text == "abc" + result = await manager.call_tool("concat_strs", {"vals": "a"}) - assert result == "a" + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].text == "a" + result = await manager.call_tool("concat_strs", {"vals": '"a"'}) - assert result == '"a"' + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].text == '"a"' async def test_call_tool_with_complex_model(self): + from mcp.types import TextContent + from fastmcp import Context class MyShrimpTank(BaseModel): @@ -342,16 +373,26 @@ class TestCallTools: manager = ToolManager() manager.add_tool_from_fn(name_shrimp) + result = await manager.call_tool( "name_shrimp", {"tank": {"x": None, "shrimp": [{"name": "rex"}, {"name": "gertrude"}]}}, ) - assert result == ["rex", "gertrude"] + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].text == '["rex", "gertrude"]' + assert json.loads(result[0].text) == ["rex", "gertrude"] + result = await manager.call_tool( "name_shrimp", {"tank": '{"x": null, "shrimp": [{"name": "rex"}, {"name": "gertrude"}]}'}, ) - assert result == ["rex", "gertrude"] + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].text == '["rex", "gertrude"]' + assert json.loads(result[0].text) == ["rex", "gertrude"] class TestToolSchema: @@ -391,6 +432,8 @@ class TestContextHandling: async def test_context_injection(self): """Test that context is properly injected during tool execution.""" + from mcp.types import TextContent + from fastmcp import Context, FastMCP def tool_with_context(x: int, ctx: Context) -> str: @@ -403,10 +446,15 @@ class TestContextHandling: mcp = FastMCP() ctx = mcp.get_context() result = await manager.call_tool("tool_with_context", {"x": 42}, context=ctx) - assert result == "42" + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].text == "42" async def test_context_injection_async(self): """Test that context is properly injected in async tools.""" + from mcp.types import TextContent + from fastmcp import Context, FastMCP async def async_tool(x: int, ctx: Context) -> str: @@ -419,10 +467,15 @@ class TestContextHandling: mcp = FastMCP() ctx = mcp.get_context() result = await manager.call_tool("async_tool", {"x": 42}, context=ctx) - assert result == "42" + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].text == "42" async def test_context_optional(self): """Test that context is optional when calling tools.""" + from mcp.types import TextContent + from fastmcp import Context def tool_with_context(x: int, ctx: Context | None = None) -> str: @@ -432,7 +485,10 @@ class TestContextHandling: manager.add_tool_from_fn(tool_with_context) # Should not raise an error when context is not provided result = await manager.call_tool("tool_with_context", {"x": 42}) - assert result == "42" + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].text == "42" async def test_context_error_handling(self): """Test error handling when context injection fails.""" @@ -450,112 +506,6 @@ class TestContextHandling: await manager.call_tool("tool_with_context", {"x": 42}, context=ctx) -class TestImportTools: - def test_import_tools(self): - """Test importing tools from one manager to another with a prefix.""" - # Setup source manager with tools - source_manager = ToolManager() - - # Create some test tools - def tool1_fn(): - return "Tool 1 result" - - def tool2_fn(): - return "Tool 2 result" - - # Add tools to source manager - source_manager.add_tool_from_fn( - tool1_fn, name="get_data", description="Get some data" - ) - source_manager.add_tool_from_fn( - tool2_fn, name="process_data", description="Process the data" - ) - - # Create target manager - target_manager = ToolManager() - - # Import tools from source to target - prefix = "source/" - target_manager.import_tools(source_manager, prefix) - - # Verify tools were imported with prefixes - assert "source/get_data" in target_manager._tools - assert "source/process_data" in target_manager._tools - - # Verify the original tools still exist in source manager - assert "get_data" in source_manager._tools - assert "process_data" in source_manager._tools - - # Verify the imported tools have the correct descriptions - assert target_manager._tools["source/get_data"].description == "Get some data" - assert ( - target_manager._tools["source/process_data"].description - == "Process the data" - ) - - # Verify the tool functions were properly copied - # We can't directly compare functions, so we'll check their __name__ attribute - assert target_manager._tools["source/get_data"].fn == tool1_fn - assert target_manager._tools["source/process_data"].fn == tool2_fn - - def test_tool_duplicate_behavior(self): - """Test the behavior when importing tools with duplicate names.""" - # Setup source and target managers - source_manager = ToolManager() - target_manager = ToolManager() - - # Add the same tool name to both managers - def source_fn(): - return "Source result" - - def target_fn(): - return "Target result" - - source_manager.add_tool_from_fn(source_fn, name="common_tool") - target_manager.add_tool_from_fn( - target_fn, name="source/common_tool" - ) # Pre-create with the prefixed name - - # Import tools from source to target - 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 == source_fn - - def test_import_tools_with_multiple_prefixes(self): - """Test importing tools from multiple managers with different prefixes.""" - # Setup source managers - weather_manager = ToolManager() - news_manager = ToolManager() - - # Add tools to source managers - def forecast_fn(): - return "Weather forecast" - - def headlines_fn(): - return "News headlines" - - weather_manager.add_tool_from_fn(forecast_fn, name="forecast") - news_manager.add_tool_from_fn(headlines_fn, name="headlines") - - # 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/") - - # Verify tools were imported with correct prefixes - assert "weather/forecast" in main_manager._tools - assert "news/headlines" in main_manager._tools - - # Verify the tools are accessible and functioning - assert ( - main_manager._tools["weather/forecast"].fn.__name__ == forecast_fn.__name__ - ) - assert ( - main_manager._tools["news/headlines"].fn.__name__ == headlines_fn.__name__ - ) - - class TestCustomToolNames: """Test adding tools with custom names that differ from their function names.""" @@ -596,6 +546,7 @@ class TestCustomToolNames: async def test_call_tool_with_custom_name(self): """Test calling a tool added with a custom name.""" + from mcp.types import TextContent def multiply(a: int, b: int) -> int: """Multiply two numbers.""" @@ -606,30 +557,16 @@ class TestCustomToolNames: # Tool should be callable by its custom name result = await manager.call_tool("custom_multiply", {"a": 5, "b": 3}) - assert result == 15 + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].text == "15" + assert json.loads(result[0].text) == 15 # Original name should not be registered - with pytest.raises(ToolError): + with pytest.raises(NotFoundError, match="Unknown tool: multiply"): await manager.call_tool("multiply", {"a": 5, "b": 3}) - def test_import_tools_with_custom_names(self): - """Test importing tools with custom names.""" - - def source_fn(x: int) -> int: - return x * 2 - - # Create a source manager with a tool using custom name - source_manager = ToolManager() - source_manager.add_tool_from_fn(source_fn, name="custom_source") - - # Import the tools to a target manager with a prefix - target_manager = ToolManager() - target_manager.import_tools(source_manager, "prefix/") - - # The tool should be imported with the prefixed custom name - assert target_manager.get_tool("prefix/custom_source") is not None - assert target_manager.get_tool("prefix/source_fn") is None - def test_replace_tool_keeps_original_name(self): """Test that replacing a tool with "replace" keeps the original name.""" diff --git a/tests/utilities/test_func_metadata.py b/tests/utilities/test_func_metadata.py index ee8037c7f..96d8c6f3b 100644 --- a/tests/utilities/test_func_metadata.py +++ b/tests/utilities/test_func_metadata.py @@ -85,7 +85,6 @@ def complex_arguments_fn( return "ok!" -@pytest.mark.anyio async def test_complex_function_runtime_arg_validation_non_json(): """Test that basic non-JSON arguments are validated correctly""" meta = func_metadata(complex_arguments_fn) @@ -122,7 +121,6 @@ async def test_complex_function_runtime_arg_validation_non_json(): ) -@pytest.mark.anyio async def test_complex_function_runtime_arg_validation_with_json(): """Test that JSON string arguments are parsed and validated correctly""" meta = func_metadata(complex_arguments_fn) @@ -199,7 +197,6 @@ def test_skip_names(): assert model.also_keep == 2.5 # type: ignore -@pytest.mark.anyio async def test_lambda_function(): """Test lambda function schema and validation""" fn = lambda x, y=5: x # noqa: E731 diff --git a/uv.lock b/uv.lock index 7f89175dd..6fb683270 100644 --- a/uv.lock +++ b/uv.lock @@ -254,10 +254,11 @@ wheels = [ [[package]] name = "fastmcp" -version = "2.1.2.dev9+55f3666" +version = "2.1.3.dev31+8d922d9" source = { editable = "." } dependencies = [ { name = "dotenv" }, + { name = "exceptiongroup" }, { name = "fastapi" }, { name = "mcp" }, { name = "openapi-pydantic" }, @@ -284,6 +285,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "dotenv", specifier = ">=0.9.9" }, + { name = "exceptiongroup", specifier = ">=1.2.2" }, { name = "fastapi", specifier = ">=0.115.12" }, { name = "mcp", specifier = ">=1.6.0,<2.0.0" }, { name = "openapi-pydantic", specifier = ">=0.5.1" }, From 5ff93f6bd6109c8391f78fd443df88558b94cd28 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 15 Apr 2025 21:03:00 -0400 Subject: [PATCH 04/12] Add mount tests --- src/fastmcp/prompts/prompt_manager.py | 4 + src/fastmcp/resources/resource_manager.py | 10 + src/fastmcp/server/server.py | 154 ++++---- src/fastmcp/settings.py | 2 +- src/fastmcp/tools/tool_manager.py | 10 +- tests/server/test_import_server.py | 11 +- tests/server/test_mount.py | 429 ++++++++++++++++++++++ tests/tools/test_tool_manager.py | 6 +- 8 files changed, 547 insertions(+), 79 deletions(-) create mode 100644 tests/server/test_mount.py diff --git a/src/fastmcp/prompts/prompt_manager.py b/src/fastmcp/prompts/prompt_manager.py index f223d1b32..a48ec91b9 100644 --- a/src/fastmcp/prompts/prompt_manager.py +++ b/src/fastmcp/prompts/prompt_manager.py @@ -77,3 +77,7 @@ class PromptManager: raise NotFoundError(f"Unknown prompt: {name}") return await prompt.render(arguments) + + def has_prompt(self, key: str) -> bool: + """Check if a prompt exists.""" + return key in self._prompts diff --git a/src/fastmcp/resources/resource_manager.py b/src/fastmcp/resources/resource_manager.py index 4a83a2315..613cd728b 100644 --- a/src/fastmcp/resources/resource_manager.py +++ b/src/fastmcp/resources/resource_manager.py @@ -202,6 +202,16 @@ class ResourceManager: self._templates[storage_key] = template return template + def has_resource(self, uri: AnyUrl | str) -> bool: + """Check if a resource exists.""" + uri_str = str(uri) + if uri_str in self._resources: + return True + for template_key in self._templates.keys(): + if match_uri_template(uri_str, template_key): + return True + return False + async def get_resource(self, uri: AnyUrl | str) -> Resource: """Get resource by URI, checking concrete resources first, then templates. diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index dc52a9f3a..facd5ff18 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -67,7 +67,6 @@ class MountedServer: tool_separator: str | None = None, resource_separator: str | None = None, prompt_separator: str | None = None, - cache_expiration_seconds: int = 10, ): if tool_separator is None: tool_separator = "_" @@ -81,54 +80,53 @@ class MountedServer: self.tool_separator = tool_separator self.resource_separator = resource_separator self.prompt_separator = prompt_separator - self.cache = TimedCache( - expiration=datetime.timedelta(seconds=cache_expiration_seconds) - ) async def get_tools(self) -> dict[str, Tool]: - cached_tools = self.cache.get("tools") - if cached_tools is NOT_FOUND: - self.cache.set("tools", {}) - cached_tools = await self.server.get_tools() - self.cache.set("tools", cached_tools) + tools = await self.server.get_tools() return { f"{self.prefix}{self.tool_separator}{key}": tool - for key, tool in cached_tools.items() + for key, tool in tools.items() } async def get_resources(self) -> dict[str, Resource]: - cached_resources = self.cache.get("resources") - if cached_resources is NOT_FOUND: - self.cache.set("resources", {}) - cached_resources = await self.server.get_resources() - self.cache.set("resources", cached_resources) - return cached_resources + resources = await self.server.get_resources() + return { + f"{self.prefix}{self.resource_separator}{key}": resource + for key, resource in resources.items() + } async def get_resource_templates(self) -> dict[str, ResourceTemplate]: - cached_templates = self.cache.get("resource_templates") - if cached_templates is NOT_FOUND: - self.cache.set("resource_templates", {}) - cached_templates = await self.server.get_resource_templates() - self.cache.set("resource_templates", cached_templates) - return cached_templates + templates = await self.server.get_resource_templates() + return { + f"{self.prefix}{self.resource_separator}{key}": template + for key, template in templates.items() + } async def get_prompts(self) -> dict[str, Prompt]: - cached_prompts = self.cache.get("prompts") - if cached_prompts is NOT_FOUND: - self.cache.set("prompts", {}) - cached_prompts = await self.server.get_prompts() - self.cache.set("prompts", cached_prompts) - return cached_prompts + prompts = await self.server.get_prompts() + return { + f"{self.prefix}{self.prompt_separator}{key}": prompt + for key, prompt in prompts.items() + } - async def match_tool(self, key: str) -> bool: + def match_tool(self, key: str) -> bool: return key.startswith(f"{self.prefix}{self.tool_separator}") - async def match_resource(self, key: str) -> bool: + def strip_tool_prefix(self, key: str) -> str: + return key.removeprefix(f"{self.prefix}{self.tool_separator}") + + def match_resource(self, key: str) -> bool: return key.startswith(f"{self.prefix}{self.resource_separator}") - async def match_prompt(self, key: str) -> bool: + def strip_resource_prefix(self, key: str) -> str: + return key.removeprefix(f"{self.prefix}{self.resource_separator}") + + def match_prompt(self, key: str) -> bool: return key.startswith(f"{self.prefix}{self.prompt_separator}") + def strip_prompt_prefix(self, key: str) -> str: + return key.removeprefix(f"{self.prefix}{self.prompt_separator}") + class TimedCache: def __init__(self, expiration: datetime.timedelta): @@ -146,6 +144,9 @@ class TimedCache: else: return NOT_FOUND + def clear(self) -> None: + self.cache.clear() + @asynccontextmanager async def default_lifespan(server: "FastMCP") -> AsyncIterator[Any]: @@ -194,7 +195,7 @@ class FastMCP(Generic[LifespanResultT]): ) ) - self._server_mounts: dict[str, MountedServer] = {} + self._mounted_servers: dict[str, MountedServer] = {} if lifespan is None: lifespan = default_lifespan @@ -287,40 +288,48 @@ class FastMCP(Generic[LifespanResultT]): async def get_tools(self) -> dict[str, Tool]: """Get all registered tools, indexed by registered key.""" - tools = {} - for server in self._server_mounts.values(): - server_tools = await server.get_tools() - tools.update(server_tools) - tools.update(self._tool_manager.get_tools()) + if (tools := self._cache.get("tools")) is NOT_FOUND: + tools = {} + for server in self._mounted_servers.values(): + server_tools = await server.get_tools() + tools.update(server_tools) + tools.update(self._tool_manager.get_tools()) + self._cache.set("tools", tools) return tools async def get_resources(self) -> dict[str, Resource]: """Get all registered resources, indexed by registered key.""" - resources = {} - for server in self._server_mounts.values(): - server_resources = await server.get_resources() - resources.update(server_resources) - resources.update(self._resource_manager.get_resources()) + if (resources := self._cache.get("resources")) is NOT_FOUND: + resources = {} + for server in self._mounted_servers.values(): + server_resources = await server.get_resources() + resources.update(server_resources) + resources.update(self._resource_manager.get_resources()) + self._cache.set("resources", resources) return resources async def get_resource_templates(self) -> dict[str, ResourceTemplate]: """Get all registered resource templates, indexed by registered key.""" - templates = {} - for server in self._server_mounts.values(): - server_templates = await server.get_resource_templates() - templates.update(server_templates) - templates.update(self._resource_manager.get_templates()) + if (templates := self._cache.get("resource_templates")) is NOT_FOUND: + templates = {} + for server in self._mounted_servers.values(): + server_templates = await server.get_resource_templates() + templates.update(server_templates) + templates.update(self._resource_manager.get_templates()) + self._cache.set("resource_templates", templates) return templates async def get_prompts(self) -> dict[str, Prompt]: """ List all available prompts. """ - prompts = {} - for server in self._server_mounts.values(): - server_prompts = await server.get_prompts() - prompts.update(server_prompts) - prompts.update(self._prompt_manager.get_prompts()) + if (prompts := self._cache.get("prompts")) is NOT_FOUND: + prompts = {} + for server in self._mounted_servers.values(): + server_prompts = await server.get_prompts() + prompts.update(server_prompts) + prompts.update(self._prompt_manager.get_prompts()) + self._cache.set("prompts", prompts) return prompts async def _mcp_list_tools(self) -> list[MCPTool]: @@ -368,14 +377,15 @@ class FastMCP(Generic[LifespanResultT]): self, key: str, arguments: dict[str, Any] ) -> list[TextContent | ImageContent | EmbeddedResource]: """Call a tool by name with arguments.""" - if self._tool_manager.get_tool(key): + if self._tool_manager.has_tool(key): context = self.get_context() result = await self._tool_manager.call_tool(key, arguments, context=context) else: - for server in self._server_mounts.values(): + for server in self._mounted_servers.values(): if server.match_tool(key): - result = await server.server._mcp_call_tool(key, arguments) + new_key = server.strip_tool_prefix(key) + result = await server.server._mcp_call_tool(new_key, arguments) break else: raise NotFoundError(f"Unknown tool: {key}") @@ -385,10 +395,9 @@ class FastMCP(Generic[LifespanResultT]): """ Read a resource by URI, in the format expected by the low-level MCP server. - - See `read_resource` for a more ergonomic way to read resources. """ - if resource := await self._resource_manager.get_resource(uri): + if self._resource_manager.has_resource(uri): + resource = await self._resource_manager.get_resource(uri) try: content = await resource.read() return [ @@ -398,9 +407,10 @@ class FastMCP(Generic[LifespanResultT]): logger.error(f"Error reading resource {uri}: {e}") raise ResourceError(str(e)) else: - for server in self._server_mounts.values(): + for server in self._mounted_servers.values(): if server.match_resource(str(uri)): - return await server.server._mcp_read_resource(uri) + new_uri = server.strip_resource_prefix(str(uri)) + return await server.server._mcp_read_resource(new_uri) else: raise NotFoundError(f"Unknown resource: {uri}") @@ -411,15 +421,15 @@ class FastMCP(Generic[LifespanResultT]): Get a prompt by name with arguments, in the format expected by the low-level MCP server. - See `get_prompt` for a more ergonomic way to get prompts. """ - if prompt := self._prompt_manager.get_prompt(name): - messages = await prompt.render(arguments) + if self._prompt_manager.has_prompt(name): + messages = await self._prompt_manager.render_prompt(name, arguments) return GetPromptResult(messages=pydantic_core.to_jsonable_python(messages)) else: - for server in self._server_mounts.values(): + for server in self._mounted_servers.values(): if server.match_prompt(name): - return await server.server._mcp_get_prompt(name, arguments) + new_key = server.strip_prompt_prefix(name) + return await server.server._mcp_get_prompt(new_key, arguments) else: raise NotFoundError(f"Unknown prompt: {name}") @@ -444,6 +454,7 @@ class FastMCP(Generic[LifespanResultT]): self._tool_manager.add_tool_from_fn( fn, name=name, description=description, tags=tags ) + self._cache.clear() def tool( self, @@ -499,6 +510,7 @@ class FastMCP(Generic[LifespanResultT]): """ self._resource_manager.add_resource(resource, key=key) + self._cache.clear() def add_resource_fn( self, @@ -530,6 +542,7 @@ class FastMCP(Generic[LifespanResultT]): mime_type=mime_type, tags=tags, ) + self._cache.clear() def resource( self, @@ -585,7 +598,7 @@ class FastMCP(Generic[LifespanResultT]): ) def decorator(fn: AnyFunction) -> AnyFunction: - self._resource_manager.add_resource_or_template_from_fn( + self.add_resource_fn( fn=fn, uri=uri, name=name, @@ -615,6 +628,7 @@ class FastMCP(Generic[LifespanResultT]): description=description, tags=tags, ) + self._cache.clear() def prompt( self, @@ -738,10 +752,12 @@ class FastMCP(Generic[LifespanResultT]): resource_separator=resource_separator, prompt_separator=prompt_separator, ) - self._server_mounts[prefix] = mounted_server + self._mounted_servers[prefix] = mounted_server + self._cache.clear() def unmount(self, prefix: str) -> None: - self._server_mounts.pop(prefix) + self._mounted_servers.pop(prefix) + self._cache.clear() async def import_server( self, @@ -815,6 +831,8 @@ class FastMCP(Generic[LifespanResultT]): logger.debug(f"Imported templates with prefix '{resource_prefix}'") logger.debug(f"Imported prompts with prefix '{prompt_prefix}'") + self._cache.clear() + @classmethod def from_openapi( cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, **settings: Any diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index 68401f68b..e82b75cc9 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -63,7 +63,7 @@ class ServerSettings(BaseSettings): ) # cache settings (for checking mounted servers) - cache_expiration_seconds: float = 5 + cache_expiration_seconds: float = 0 class ClientSettings(BaseSettings): diff --git a/src/fastmcp/tools/tool_manager.py b/src/fastmcp/tools/tool_manager.py index 6da7d0961..0675cfc65 100644 --- a/src/fastmcp/tools/tool_manager.py +++ b/src/fastmcp/tools/tool_manager.py @@ -37,9 +37,15 @@ class ToolManager: self.duplicate_behavior = duplicate_behavior - def get_tool(self, key: str) -> Tool | None: + def has_tool(self, key: str) -> bool: + """Check if a tool exists.""" + return key in self._tools + + def get_tool(self, key: str) -> Tool: """Get tool by key.""" - return self._tools.get(key) + if key in self._tools: + return self._tools[key] + raise NotFoundError(f"Unknown tool: {key}") def get_tools(self) -> dict[str, Tool]: """Get all registered tools, indexed by registered key.""" diff --git a/tests/server/test_import_server.py b/tests/server/test_import_server.py index 4583d4406..c9160e4b1 100644 --- a/tests/server/test_import_server.py +++ b/tests/server/test_import_server.py @@ -221,12 +221,10 @@ async def test_call_imported_custom_named_tool(): api_app.add_tool(fetch_data, name="get_data") await main_app.import_server("api", api_app) - context = main_app.get_context() - result = await main_app._tool_manager.call_tool( - "api_get_data", {"query": "test"}, context=context - ) - assert isinstance(result[0], TextContent) - assert result[0].text == "Data for query: test" + async with Client(main_app) as client: + result = await client.call_tool("api_get_data", {"query": "test"}) + assert isinstance(result[0], TextContent) + assert result[0].text == "Data for query: test" async def test_first_level_importing_with_custom_name(): @@ -382,6 +380,7 @@ async def test_import_with_proxy_resource_templates(): await main_app.import_server("api", proxy_app) # Instantiate the template through the main app with the prefixed key + quoted_name = quote("John Doe", safe="") quoted_email = quote("john@example.com", safe="") async with Client(main_app) as client: diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py new file mode 100644 index 000000000..61d958868 --- /dev/null +++ b/tests/server/test_mount.py @@ -0,0 +1,429 @@ +import json + +import pytest +from mcp.server.lowlevel.helper_types import ReadResourceContents +from mcp.types import TextContent, TextResourceContents + +from fastmcp import FastMCP +from fastmcp.client import Client +from fastmcp.client.transports import FastMCPTransport +from fastmcp.exceptions import NotFoundError + + +class TestBasicMount: + """Test basic mounting functionality.""" + + async def test_mount_simple_server(self): + """Test mounting a simple server and accessing its tool.""" + # Create main app and sub-app + main_app = FastMCP("MainApp") + sub_app = FastMCP("SubApp") + + # Add a tool to the sub-app + @sub_app.tool() + def sub_tool() -> str: + return "This is from the sub app" + + # Mount the sub-app to the main app + main_app.mount("sub", sub_app) + + # Get tools from main app, should include sub_app's tools + tools = await main_app.get_tools() + assert "sub_sub_tool" in tools + + async with Client(main_app) as client: + result = await client.call_tool("sub_sub_tool", {}) + assert isinstance(result[0], TextContent) + assert result[0].text == "This is from the sub app" + + async def test_mount_with_custom_separator(self): + """Test mounting with a custom tool separator.""" + main_app = FastMCP("MainApp") + sub_app = FastMCP("SubApp") + + @sub_app.tool() + def greet(name: str) -> str: + return f"Hello, {name}!" + + # Mount with custom separator + main_app.mount("sub", sub_app, tool_separator="-") + + # Tool should be accessible with custom separator + tools = await main_app.get_tools() + assert "sub-greet" in tools + + # Call the tool + result = await main_app._mcp_call_tool("sub-greet", {"name": "World"}) + assert isinstance(result[0], TextContent) + assert result[0].text == "Hello, World!" + + async def test_unmount_server(self): + """Test unmounting a server removes access to its tools.""" + main_app = FastMCP("MainApp") + sub_app = FastMCP("SubApp") + + @sub_app.tool() + def sub_tool() -> str: + return "This is from the sub app" + + # Mount the sub-app + main_app.mount("sub", sub_app) + + # Verify it was mounted + tools = await main_app.get_tools() + assert "sub_sub_tool" in tools + + # Unmount the sub-app + main_app.unmount("sub") + + # Verify it was unmounted + tools = await main_app.get_tools() + assert "sub_sub_tool" not in tools + + # Calling the tool should fail + with pytest.raises(NotFoundError, match="Unknown tool: sub_sub_tool"): + await main_app._mcp_call_tool("sub_sub_tool", {}) + + +class TestMultipleServerMount: + """Test mounting multiple servers simultaneously.""" + + async def test_mount_multiple_servers(self): + """Test mounting multiple servers with different prefixes.""" + main_app = FastMCP("MainApp") + weather_app = FastMCP("WeatherApp") + news_app = FastMCP("NewsApp") + + @weather_app.tool() + def get_forecast() -> str: + return "Weather forecast" + + @news_app.tool() + def get_headlines() -> str: + return "News headlines" + + # Mount both apps + main_app.mount("weather", weather_app) + main_app.mount("news", news_app) + + # Check both are accessible + tools = await main_app.get_tools() + assert "weather_get_forecast" in tools + assert "news_get_headlines" in tools + + # Call tools from both mounted servers + result1 = await main_app._mcp_call_tool("weather_get_forecast", {}) + assert isinstance(result1[0], TextContent) + assert result1[0].text == "Weather forecast" + + result2 = await main_app._mcp_call_tool("news_get_headlines", {}) + assert isinstance(result2[0], TextContent) + assert result2[0].text == "News headlines" + + async def test_mount_same_prefix(self): + """Test that mounting with the same prefix replaces the previous mount.""" + main_app = FastMCP("MainApp") + first_app = FastMCP("FirstApp") + second_app = FastMCP("SecondApp") + + @first_app.tool() + def first_tool() -> str: + return "First app tool" + + @second_app.tool() + def second_tool() -> str: + return "Second app tool" + + # Mount first app + main_app.mount("api", first_app) + tools = await main_app.get_tools() + assert "api_first_tool" in tools + + # Mount second app with same prefix + main_app.mount("api", second_app) + tools = await main_app.get_tools() + + # First app's tool should no longer be accessible + assert "api_first_tool" not in tools + + # Second app's tool should be accessible + assert "api_second_tool" in tools + + +class TestDynamicChanges: + """Test that changes to mounted servers are reflected dynamically.""" + + async def test_adding_tool_after_mounting(self): + """Test that tools added after mounting are accessible.""" + main_app = FastMCP("MainApp") + sub_app = FastMCP("SubApp") + + # Mount the sub-app before adding any tools + main_app.mount("sub", sub_app) + + # Initially, there should be no tools from sub_app + tools = await main_app.get_tools() + assert not any(key.startswith("sub_") for key in tools) + + # Add a tool to the sub-app after mounting + @sub_app.tool() + def dynamic_tool() -> str: + return "Added after mounting" + + # The tool should be accessible through the main app + tools = await main_app.get_tools() + assert "sub_dynamic_tool" in tools + + # Call the dynamically added tool + result = await main_app._mcp_call_tool("sub_dynamic_tool", {}) + assert isinstance(result[0], TextContent) + assert result[0].text == "Added after mounting" + + async def test_removing_tool_after_mounting(self): + """Test that tools removed from mounted servers are no longer accessible.""" + main_app = FastMCP("MainApp") + sub_app = FastMCP("SubApp") + + @sub_app.tool() + def temp_tool() -> str: + return "Temporary tool" + + # Mount the sub-app + main_app.mount("sub", sub_app) + + # Initially, the tool should be accessible + tools = await main_app.get_tools() + assert "sub_temp_tool" in tools + + # Remove the tool from sub_app + sub_app._tool_manager._tools.pop("temp_tool") + + # The tool should no longer be accessible + # Refresh the cache by clearing it + main_app._cache.cache.clear() + tools = await main_app.get_tools() + assert "sub_temp_tool" not in tools + + +class TestResourcesAndTemplates: + """Test mounting with resources and resource templates.""" + + async def test_mount_with_resources(self): + """Test mounting a server with resources.""" + main_app = FastMCP("MainApp") + data_app = FastMCP("DataApp") + + @data_app.resource(uri="data://users") + async def get_users(): + return ["user1", "user2"] + + # Mount the data app + main_app.mount("data", data_app) + + # Resource should be accessible through main app + resources = await main_app.get_resources() + assert any("data+data://users" in str(uri) for uri in resources) + + async with Client(main_app) as client: + resource = await client.read_resource("data+data://users") + assert isinstance(resource[0], TextResourceContents) + assert resource[0].text == '["user1", "user2"]' + + async def test_mount_with_resource_templates(self): + """Test mounting a server with resource templates.""" + main_app = FastMCP("MainApp") + user_app = FastMCP("UserApp") + + @user_app.resource(uri="users://{user_id}/profile") + def get_user_profile(user_id: str) -> dict: + return {"id": user_id, "name": f"User {user_id}"} + + # Mount the user app + main_app.mount("api", user_app) + + # Template should be accessible through main app + templates = await main_app.get_resource_templates() + assert any("api+users://{user_id}/profile" in str(t) for t in templates) + + # Read from the template + result = await main_app._mcp_read_resource("api+users://123/profile") + assert isinstance(result[0], ReadResourceContents) + profile = json.loads(result[0].content) + assert profile["id"] == "123" + assert profile["name"] == "User 123" + + async def test_adding_resource_after_mounting(self): + """Test adding a resource after mounting.""" + main_app = FastMCP("MainApp") + data_app = FastMCP("DataApp") + + # Mount the data app before adding resources + main_app.mount("data", data_app) + + # Add a resource after mounting + @data_app.resource(uri="data://config") + def get_config(): + return {"version": "1.0"} + + # Resource should be accessible through main app + resources = await main_app.get_resources() + assert any("data+data://config" in str(uri) for uri in resources) + + # Read the resource + result = await main_app._mcp_read_resource("data+data://config") + assert isinstance(result[0], ReadResourceContents) + config = json.loads(result[0].content) + assert config["version"] == "1.0" + + +class TestPrompts: + """Test mounting with prompts.""" + + async def test_mount_with_prompts(self): + """Test mounting a server with prompts.""" + main_app = FastMCP("MainApp") + assistant_app = FastMCP("AssistantApp") + + @assistant_app.prompt() + def greeting(name: str) -> str: + return f"Hello, {name}!" + + # Mount the assistant app + main_app.mount("assistant", assistant_app) + + # Prompt should be accessible through main app + prompts = await main_app.get_prompts() + assert "assistant_greeting" in prompts + + # Render the prompt + result = await main_app._mcp_get_prompt("assistant_greeting", {"name": "World"}) + assert result.messages is not None + # The message should contain our greeting text + + async def test_adding_prompt_after_mounting(self): + """Test adding a prompt after mounting.""" + main_app = FastMCP("MainApp") + assistant_app = FastMCP("AssistantApp") + + # Mount the assistant app before adding prompts + main_app.mount("assistant", assistant_app) + + # Add a prompt after mounting + @assistant_app.prompt() + def farewell(name: str) -> str: + return f"Goodbye, {name}!" + + # Prompt should be accessible through main app + prompts = await main_app.get_prompts() + assert "assistant_farewell" in prompts + + # Render the prompt + result = await main_app._mcp_get_prompt("assistant_farewell", {"name": "World"}) + assert result.messages is not None + # The message should contain our farewell text + + +class TestProxyServer: + """Test mounting a proxy server.""" + + async def test_mount_proxy_server(self): + """Test mounting a proxy server.""" + # Create original server + original_server = FastMCP("OriginalServer") + + @original_server.tool() + def get_data(query: str) -> str: + return f"Data for {query}" + + # Create proxy server + proxy_server = FastMCP.from_client( + Client(transport=FastMCPTransport(original_server)) + ) + + # Mount proxy server + main_app = FastMCP("MainApp") + main_app.mount("proxy", proxy_server) + + # Tool should be accessible through main app + tools = await main_app.get_tools() + assert "proxy_get_data" in tools + + # Call the tool + result = await main_app._mcp_call_tool("proxy_get_data", {"query": "test"}) + assert isinstance(result[0], TextContent) + assert result[0].text == "Data for test" + + async def test_dynamically_adding_to_proxied_server(self): + """Test that changes to the original server are reflected in the mounted proxy.""" + # Create original server + original_server = FastMCP("OriginalServer") + + # Create proxy server + proxy_server = FastMCP.from_client( + Client(transport=FastMCPTransport(original_server)) + ) + + # Mount proxy server + main_app = FastMCP("MainApp") + main_app.mount("proxy", proxy_server) + + # Add a tool to the original server + @original_server.tool() + def dynamic_data() -> str: + return "Dynamic data" + + # Tool should be accessible through main app via proxy + tools = await main_app.get_tools() + assert "proxy_dynamic_data" in tools + + # Call the tool + result = await main_app._mcp_call_tool("proxy_dynamic_data", {}) + assert isinstance(result[0], TextContent) + assert result[0].text == "Dynamic data" + + async def test_proxy_server_with_resources(self): + """Test mounting a proxy server with resources.""" + # Create original server + original_server = FastMCP("OriginalServer") + + @original_server.resource(uri="config://settings") + def get_config(): + return {"api_key": "12345"} + + # Create proxy server + proxy_server = FastMCP.from_client( + Client(transport=FastMCPTransport(original_server)) + ) + + # Mount proxy server + main_app = FastMCP("MainApp") + main_app.mount("proxy", proxy_server) + + # Resource should be accessible through main app + result = await main_app._mcp_read_resource("proxy+config://settings") + assert isinstance(result[0], ReadResourceContents) + config = json.loads(result[0].content) + assert config["api_key"] == "12345" + + async def test_proxy_server_with_prompts(self): + """Test mounting a proxy server with prompts.""" + # Create original server + original_server = FastMCP("OriginalServer") + + @original_server.prompt() + def welcome(name: str) -> str: + return f"Welcome, {name}!" + + # Create proxy server + proxy_server = FastMCP.from_client( + Client(transport=FastMCPTransport(original_server)) + ) + + # Mount proxy server + main_app = FastMCP("MainApp") + main_app.mount("proxy", proxy_server) + + # Prompt should be accessible through main app + result = await main_app._mcp_get_prompt("proxy_welcome", {"name": "World"}) + assert result.messages is not None + # The message should contain our welcome text diff --git a/tests/tools/test_tool_manager.py b/tests/tools/test_tool_manager.py index de86b5192..8206fd645 100644 --- a/tests/tools/test_tool_manager.py +++ b/tests/tools/test_tool_manager.py @@ -523,7 +523,8 @@ class TestCustomToolNames: assert tool.name == "custom_name" assert tool.fn.__name__ == "original_fn" # The tool should not be accessible via its original function name - assert manager.get_tool("original_fn") is None + with pytest.raises(NotFoundError, match="Unknown tool: original_fn"): + manager.get_tool("original_fn") def test_add_tool_object_with_custom_key(self): """Test adding a Tool object with a custom key using add_tool().""" @@ -542,7 +543,8 @@ class TestCustomToolNames: # But the tool's .name is unchanged assert stored.name == "my_tool" # The tool is not accessible under its original name - assert manager.get_tool("my_tool") is None + with pytest.raises(NotFoundError, match="Unknown tool: my_tool"): + manager.get_tool("my_tool") async def test_call_tool_with_custom_name(self): """Test calling a tool added with a custom name.""" From 526f1993c24d5af6e2794397a017e9250bc9288c Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 15 Apr 2025 21:29:15 -0400 Subject: [PATCH 05/12] update docs --- README.md | 6 +- docs/clients/overview.mdx | 2 +- docs/patterns/composition.mdx | 136 +++++++++++++++++++++++++++++----- docs/patterns/proxying.mdx | 23 +++--- docs/servers/fastmcp.mdx | 108 +++++++++++++++++++++++---- 5 files changed, 225 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 387b50437..a27c58fa5 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ FastMCP provides a high-level, Pythonic interface for building and interacting w ## Why FastMCP? -The MCP protocol is powerful but implementing it involves a lot of boilerplate - server setup, protocol handlers, content types, error management. FastMCP handles all the complex protocol details and server management, so you can focus on building great tools. It’s designed to be high-level and Pythonic; in most cases, decorating a function is all you need. +The MCP protocol is powerful but implementing it involves a lot of boilerplate - server setup, protocol handlers, content types, error management. FastMCP handles all the complex protocol details and server management, so you can focus on building great tools. It's designed to be high-level and Pythonic; in most cases, decorating a function is all you need. FastMCP aims to be: @@ -552,13 +552,13 @@ proxy_client = Client( ) # Create a proxy server that connects to the client and exposes its capabilities -proxy = FastMCP.as_proxy(proxy_client, name="Stdio-to-SSE Proxy") +proxy = FastMCP.from_client(proxy_client, name="Stdio-to-SSE Proxy") if __name__ == "__main__": proxy.run(transport='sse') ``` -`FastMCP.as_proxy` is an `async` classmethod. It connects to the target, discovers its capabilities, and dynamically builds the proxy server instance. +`FastMCP.from_client` is a class method that connects to the target, discovers its capabilities, and dynamically builds the proxy server instance. diff --git a/docs/clients/overview.mdx b/docs/clients/overview.mdx index 7f4212f08..5a381ff68 100644 --- a/docs/clients/overview.mdx +++ b/docs/clients/overview.mdx @@ -248,5 +248,5 @@ async def safe_call_tool(): Other errors, like connection failures, will raise standard Python exceptions (e.g., `ConnectionError`, `TimeoutError`). -The client transport often has its own error-handling mechanisms, so you can not always trap errors like those raised by `call_tool` outside of the `async with` block. Instead, you can call `call_tools(..., return_raw_result=True)` to get the raw result object and handle errors yourself by checking its `isError` attribute. +The client transport often has its own error-handling mechanisms, so you can not always trap errors like those raised by `call_tool` outside of the `async with` block. Instead, you can call `call_tool(..., _return_raw_result=True)` to get the raw `mcp.types.CallToolResult` object and handle errors yourself by checking its `isError` attribute. diff --git a/docs/patterns/composition.mdx b/docs/patterns/composition.mdx index f9a531372..d00522595 100644 --- a/docs/patterns/composition.mdx +++ b/docs/patterns/composition.mdx @@ -1,11 +1,14 @@ --- title: Server Composition sidebarTitle: Composition -description: Combine multiple FastMCP servers into a single, larger application using mounting. +description: Combine multiple FastMCP servers into a single, larger application using mounting and importing. icon: puzzle-piece --- -As your MCP applications grow, you might want to organize your tools, resources, and prompts into logical modules or reuse existing server components. FastMCP supports composition through the `server.mount()` method, allowing you to combine multiple `FastMCP` instances into a single, unified server. +As your MCP applications grow, you might want to organize your tools, resources, and prompts into logical modules or reuse existing server components. FastMCP supports composition through two methods: + +- **`import_server`**: For a one-time copy of components with prefixing (static composition). +- **`mount`**: For creating a live link where the main server delegates requests to the subserver (dynamic composition). ## Why Compose Servers? @@ -14,13 +17,14 @@ As your MCP applications grow, you might want to organize your tools, resources, - **Teamwork**: Different teams can work on separate FastMCP servers that are later combined. - **Organization**: Keep related functionality grouped together logically. -## Mounting Subservers +## Importing Subservers (Static Composition) -The `mount()` method attaches all components (tools, resources, templates, prompts) from one `FastMCP` instance (the *subserver*) onto another (the *main server*). A `prefix` is added to avoid naming conflicts. +The `import_server()` method copies all components (tools, resources, templates, prompts) from one `FastMCP` instance (the *subserver*) into another (the *main server*). A `prefix` is added to avoid naming conflicts. ```python from fastmcp import FastMCP from typing import dict, list +import asyncio # --- Define Subservers --- @@ -53,14 +57,15 @@ def explain_addition() -> str: # --- Define Main Server --- main_mcp = FastMCP(name="MainApp") -# --- Mount Subservers --- -# Mount weather service with prefix "weather" -main_mcp.mount("weather", weather_mcp) +# --- Import Subservers --- +async def setup(): + # Import weather service with prefix "weather" + await main_mcp.import_server("weather", weather_mcp) -# Mount calculator service with prefix "calc" -main_mcp.mount("calc", calc_mcp) + # Import calculator service with prefix "calc" + await main_mcp.import_server("calc", calc_mcp) -# --- Now, main_mcp contains combined components --- +# --- Now, main_mcp contains *copied* components --- # Tools: # - "weather_get_forecast" # - "calc_add" @@ -70,13 +75,15 @@ main_mcp.mount("calc", calc_mcp) # - "calc_explain_addition" if __name__ == "__main__": + # In a real app, you might run this async or setup imports differently + asyncio.run(setup()) # Run the main server, which now includes components from both subservers main_mcp.run() ``` -### How Mounting Works +### How Importing Works -When you call `main_mcp.mount(prefix, subserver)`: +When you call `await main_mcp.import_server(prefix, subserver)`: 1. **Tools**: All tools from `subserver` are added to `main_mcp`. Their names are automatically prefixed using the `prefix` and a default separator (`_`). - `subserver.tool(name="my_tool")` becomes `main_mcp.tool(name="{prefix}_my_tool")`. @@ -86,14 +93,15 @@ When you call `main_mcp.mount(prefix, subserver)`: - `subserver.resource(uri="data://{id}")` becomes `main_mcp.resource(uri="{prefix}+data://{id}")`. 4. **Prompts**: All prompts from `subserver` are added, with names prefixed like tools. - `subserver.prompt(name="my_prompt")` becomes `main_mcp.prompt(name="{prefix}_my_prompt")`. -5. **Lifespan Management**: If the `subserver` has a `lifespan` function defined, it will be automatically executed within the `main_mcp`'s lifespan context. This ensures that setup and teardown logic for the subserver runs correctly. + +Note that `import_server` performs a **one-time copy** of components from the `subserver` into the `main_mcp` instance at the time the method is called. Changes made to the `subserver` *after* `import_server` is called **will not** be reflected in `main_mcp`. Also, the `subserver`'s `lifespan` context is **not** executed by the main server when using `import_server`. ### Customizing Separators -You might prefer different separators for the prefixed names and URIs. You can customize these when calling `mount()`: +You might prefer different separators for the prefixed names and URIs. You can customize these when calling `import_server()`: ```python -main_mcp.mount( +await main_mcp.import_server( prefix="api", app=some_subserver, tool_separator="/", # Tool name becomes: "api/sub_tool_name" @@ -106,8 +114,92 @@ main_mcp.mount( Be cautious when choosing separators. Some MCP clients (like Claude Desktop) might have restrictions on characters allowed in tool names (e.g., `/` might not be supported). The defaults (`_` for names, `+` for URIs) are generally safe. +## Mounting Subservers (Live Linking) + +The `mount()` method creates a **live link** between the `main_mcp` server and the `subserver`. Instead of copying components, requests for components matching the `prefix` are **delegated** to the `subserver` at runtime. + +```python +import asyncio +from fastmcp import FastMCP, Client + +# --- Define Subserver --- +dynamic_mcp = FastMCP(name="DynamicService") +@dynamic_mcp.tool() +def initial_tool(): return "Initial Tool Exists" + +# --- Define Main Server --- +main_mcp = FastMCP(name="MainAppLive") + +# --- Mount Subserver (Sync operation) --- +main_mcp.mount("dynamic", dynamic_mcp) + +print("Mounted dynamic_mcp.") + +# --- Add a tool AFTER mounting --- +@dynamic_mcp.tool() +def added_later(): return "Tool Added Dynamically!" + +print("Added 'added_later' tool to dynamic_mcp.") + +# --- Test Access --- +async def test_dynamic_mount(): + # Need to use await for get_tools now + tools_before = await main_mcp.get_tools() + print("Tools available via main_mcp:", list(tools_before.keys())) + # Expected: ['dynamic_initial_tool', 'dynamic_added_later'] + + async with Client(main_mcp) as client: + # Call the dynamically added tool via the main server + result = await client.call_tool("dynamic_added_later") + print("Result of calling dynamic_added_later:", result[0].text) + # Expected: Tool Added Dynamically! + +if __name__ == "__main__": + # Need async context to test + asyncio.run(test_dynamic_mount()) + # To run the server itself: + # main_mcp.run() +``` + +### How Mounting Works + +When you call `main_mcp.mount(prefix, server)`: + +1. **Live Link**: A live connection is established between `main_mcp` and the `subserver`. +2. **Dynamic Updates**: Changes made to the `subserver` (e.g., adding new tools) **will be reflected** immediately when accessing components through `main_mcp`. +3. **Lifespan Management**: The `subserver`'s `lifespan` context **is automatically managed** and executed within the `main_mcp`'s lifespan. +4. **Delegation**: Requests for components matching the prefix are delegated to the subserver at runtime. + +The same prefixing rules apply as with `import_server` for naming tools, resources, templates, and prompts. + +### Customizing Separators + +Similar to `import_server`, you can customize the separators for the prefixed names and URIs: + +```python +main_mcp.mount( + prefix="api", + app=some_subserver, + tool_separator="/", # Tool name becomes: "api/sub_tool_name" + resource_separator=":", # Resource URI becomes: "api:data://sub_resource" + prompt_separator="." # Prompt name becomes: "api.sub_prompt_name" +) +``` + +## Comparing Import and Mount + +| Feature | `import_server` | `mount` | +|---------|----------------|---------| +| **Synchronicity** | Async (must be awaited) | Sync | +| **Composition Type** | One-time copy (static) | Live link (dynamic) | +| **Updates** | Changes to subserver NOT reflected | Changes to subserver immediately reflected | +| **Lifespan** | Not managed | Automatically managed | +| **Best For** | Bundling finalized components | Modular runtime composition | + ## Example: Modular Application +Here's how a modular application might use `import_server`: + ```python # modules/text_utils.py from fastmcp import FastMCP @@ -146,20 +238,23 @@ def get_table_schema(table: str) -> dict: # ------------------------------ # main_app.py from fastmcp import FastMCP +import asyncio from modules.text_utils import text_mcp # Import server instances from modules.data_api import data_mcp app = FastMCP(name="MainApplication") -# Mount the utility servers -app.mount("text", text_mcp) -app.mount("data", data_mcp) +# Setup function for async imports +async def setup(): + # Import the utility servers + await app.import_server("text", text_mcp) + await app.import_server("data", data_mcp) @app.tool() def process_and_analyze(record_id: int) -> str: """Fetches a record and analyzes its string representation.""" # In a real application, you'd use proper methods to interact between - # mounted tools rather than accessing internal managers + # imported tools rather than accessing internal managers # Get record data record = {"id": record_id, "value": random.random()} @@ -173,6 +268,9 @@ def process_and_analyze(record_id: int) -> str: ) if __name__ == "__main__": + # Run async setup before starting the server + asyncio.run(setup()) + # Run the server app.run() ``` diff --git a/docs/patterns/proxying.mdx b/docs/patterns/proxying.mdx index d880b95b2..e0204446e 100644 --- a/docs/patterns/proxying.mdx +++ b/docs/patterns/proxying.mdx @@ -5,7 +5,7 @@ description: Use FastMCP to act as an intermediary or change transport for other icon: arrows-retweet --- -FastMCP provides a powerful proxying capability that allows one FastMCP server instance to act as a frontend for another MCP server (which could be remote, running on a different transport, or even another FastMCP instance). This is achieved using the `FastMCP.as_proxy()` class method. +FastMCP provides a powerful proxying capability that allows one FastMCP server instance to act as a frontend for another MCP server (which could be remote, running on a different transport, or even another FastMCP instance). This is achieved using the `FastMCP.from_client()` class method. ## What is Proxying? @@ -32,7 +32,7 @@ sequenceDiagram ## Creating a Proxy -The easiest way to create a proxy is using the `FastMCP.as_proxy()` class method. This creates a standard FastMCP server that forwards requests to another MCP server. +The easiest way to create a proxy is using the `FastMCP.from_client()` class method. This creates a standard FastMCP server that forwards requests to another MCP server. ```python from fastmcp import FastMCP, Client @@ -41,8 +41,8 @@ from fastmcp import FastMCP, Client # This could be any MCP server - remote, local, or using any transport backend_client = Client("backend_server.py") # Could be "http://remote.server/sse", etc. -# Create the proxy server with as_proxy() -proxy_server = await FastMCP.as_proxy( +# Create the proxy server with from_client() +proxy_server = FastMCP.from_client( backend_client, name="MyProxyServer" # Optional settings for the proxy ) @@ -51,13 +51,17 @@ proxy_server = await FastMCP.as_proxy( # with any transport (SSE, stdio, etc.) just like any other FastMCP server ``` -**How `as_proxy` Works:** +**How `from_client` Works:** 1. It connects to the backend server using the provided client. 2. It discovers all the tools, resources, resource templates, and prompts available on the backend server. 3. It creates corresponding "proxy" components that forward requests to the backend. 4. It returns a standard `FastMCP` server instance that can be used like any other. + +Currently, proxying focuses primarily on exposing the major MCP objects (tools, resources, templates, and prompts). Some advanced MCP features like notifications and sampling are not fully supported in proxies in the current version. Support for these additional features may be added in future releases. + + ### Bridging Transports A common use case is to bridge transports. For example, making a remote SSE server available locally via Stdio: @@ -69,7 +73,7 @@ from fastmcp import FastMCP, Client client = Client("http://example.com/mcp/sse") # Create a proxy server - it's just a regular FastMCP server -proxy = await FastMCP.as_proxy(client, name="SSE to Stdio Proxy") +proxy = FastMCP.from_client(client, name="SSE to Stdio Proxy") # The proxy can now be used with any transport # No special handling needed - it works like any FastMCP server @@ -90,7 +94,7 @@ def tool_a() -> str: return "A" # Create a proxy of the original server -proxy = await FastMCP.as_proxy( +proxy = FastMCP.from_client( original_server, name="Proxy Server" ) @@ -101,9 +105,6 @@ proxy = await FastMCP.as_proxy( ## `FastMCPProxy` Class -Internally, `FastMCP.as_proxy()` uses the `FastMCPProxy` class. You generally don't need to interact with this class directly, but it's available if needed. It has two primary async constructors: - -* `FastMCPProxy.from_client(client: Client, **settings)`: Creates a proxy from a client instance. -* `FastMCPProxy.from_server(server: FastMCP, **settings)`: Creates a proxy from another FastMCP server instance. +Internally, `FastMCP.from_client()` uses the `FastMCPProxy` class. You generally don't need to interact with this class directly, but it's available if needed. Using the class directly might be necessary for advanced scenarios, like subclassing `FastMCPProxy` to add custom logic before or after forwarding requests. \ No newline at end of file diff --git a/docs/servers/fastmcp.mdx b/docs/servers/fastmcp.mdx index d950dcea0..d07cc219d 100644 --- a/docs/servers/fastmcp.mdx +++ b/docs/servers/fastmcp.mdx @@ -223,17 +223,22 @@ fastmcp run my_server.py:mcp --transport sse --log-level DEBUG The CLI can dynamically find and run FastMCP server objects in your files, but including the `if __name__ == "__main__":` block ensures compatibility with all clients. +## Composing Servers -## Mounting Subservers +FastMCP provides two methods for composing multiple servers together: -FastMCP allows you to compose complex applications by mounting other FastMCP servers as subservers. This is useful for: +1. `import_server()`: One-time static import of components (async) +2. `mount()`: Live link delegating to subservers (sync) -- Organizing large applications into logical components -- Reusing existing FastMCP servers as parts of a larger system -- Creating domain-specific servers that can be used independently or composed +This allows you to organize large applications into logical components, reuse existing FastMCP servers, and create domain-specific servers that can be used independently or composed. + +### Importing Subservers (Static Composition) + +The `import_server()` method performs a one-time copy of all components from a subserver into the main server with prefixed names: ```python from fastmcp import FastMCP +import asyncio # Create the main server main_mcp = FastMCP(name="MainServer") @@ -254,54 +259,125 @@ def add(a: float, b: float) -> float: """Add two numbers.""" return a + b -# Mount the subservers with prefixes -main_mcp.mount("weather", weather_mcp) -main_mcp.mount("calc", calculator_mcp) +# Import the subservers with prefixes +async def setup(): + await main_mcp.import_server("weather", weather_mcp) + await main_mcp.import_server("calc", calculator_mcp) # Now main_mcp has access to both subservers' tools: # - "weather_get_forecast" (from weather_mcp) # - "calc_add" (from calculator_mcp) if __name__ == "__main__": + # Run async setup + asyncio.run(setup()) + # Then run the server main_mcp.run() ``` -### How Mounting Works +#### How Import Works -When you mount a server with `main_mcp.mount(prefix, subserver)`: +When you import a server with `await main_mcp.import_server(prefix, subserver)`: -1. All tools from the subserver are imported with prefixed names: +1. All tools from the subserver are copied with prefixed names: - `tool_name` becomes `{prefix}_tool_name` - Default separator is `_`, but can be customized -2. All resources and resource templates are imported with prefixed URIs: +2. All resources and resource templates are copied with prefixed URIs: - `resource://data` becomes `{prefix}+resource://data` - Default separator is `+`, but can be customized -3. All prompts are imported with prefixed names: +3. All prompts are copied with prefixed names: - `prompt_name` becomes `{prefix}_prompt_name` - Default separator is `_`, but can be customized -4. The subserver's lifespan is managed automatically when the main server starts and stops +4. This is a **one-time copy** - changes to the subserver after importing won't be reflected in the main server + +5. The subserver's lifespan is **not** managed by the main server + +### Mounting Subservers (Live Linking) + +The `mount()` method creates a live link between servers, delegating requests to the appropriate subserver: + +```python +from fastmcp import FastMCP + +# Create the main server +main_mcp = FastMCP(name="MainServer") + +# Create a domain-specific subserver +weather_mcp = FastMCP(name="WeatherService") + +@weather_mcp.tool() +def get_forecast(city: str) -> dict: + """Get the weather forecast for a city.""" + return {"city": city, "forecast": "Sunny", "temperature": 72} + +# Mount the subserver (sync operation) +main_mcp.mount("weather", weather_mcp) + +# Later, add another tool to the subserver +@weather_mcp.tool() +def get_temperature(city: str) -> float: + """Get the current temperature for a city.""" + return 72.5 # Example value + +# The new tool is automatically available through the main server +# as "weather_get_temperature" + +if __name__ == "__main__": + main_mcp.run() +``` + +#### How Mounting Works + +When you mount a server with `main_mcp.mount(prefix, subserver)`: + +1. A live link is created between the main server and the subserver +2. Requests for components matching the prefix are delegated to the subserver +3. Changes to the subserver are **immediately reflected** when accessing through the main server +4. The subserver's lifespan **is automatically managed** by the main server ### Customizing Separators -You can customize the separators used for naming: +For both `import_server()` and `mount()`, you can customize the separators used for naming: ```python -main_mcp.mount( +# For import_server (async) +await main_mcp.import_server( "weather", weather_mcp, tool_separator="-", # Use "weather-get_forecast" instead of "weather_get_forecast" resource_separator=".", # Use "weather.resource://data" instead of "weather+resource://data" prompt_separator=":" # Use "weather:prompt_name" instead of "weather_prompt_name" ) + +# For mount (sync) +main_mcp.mount( + "weather", + weather_mcp, + tool_separator="-", + resource_separator=".", + prompt_separator=":" +) ``` Some MCP clients may reject certain separators as invalid. For example, Claude Desktop does not support `/` in tool names. +### Comparison + +| Feature | `import_server()` | `mount()` | +|---------|------------------|-----------| +| **Synchronicity** | Async (must be awaited) | Sync | +| **Composition Type** | One-time copy (static) | Live link (dynamic) | +| **Updates** | Changes to subserver NOT reflected | Changes immediately reflected | +| **Lifespan** | Not managed | Automatically managed | +| **Best For** | Bundling finalized components | Modular runtime composition | + +For more detailed examples, see the [Server Composition](/patterns/composition) guide. + ## Server Configuration Server behavior, like transport settings (host, port for SSE) and how duplicate components are handled, can be configured via `ServerSettings`. These settings can be passed during `FastMCP` initialization, set via environment variables (prefixed with `FASTMCP_SERVER_`), or loaded from a `.env` file. From e381261026e4b0a8b9253a5b40b21aada7543fa3 Mon Sep 17 00:00:00 2001 From: kongqi <32472226+kongqi404@users.noreply.github.com> Date: Wed, 16 Apr 2025 13:24:30 +0000 Subject: [PATCH 06/12] Add npx validation --- src/fastmcp/client/transports.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 27b885688..c3c2b797b 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import ( TypedDict, ) - +import shutil from mcp import ClientSession, StdioServerParameters from mcp.client.session import ( ListRootsFnT, @@ -341,6 +341,10 @@ class NpxStdioTransport(StdioTransport): env_vars: Additional environment variables use_package_lock: Whether to use package-lock.json (--prefer-offline) """ + # verify npx is installed + if shutil.which("npx") is None: + raise ValueError("Command 'npx' not found") + # Basic validation if project_directory and not Path(project_directory).exists(): raise NotADirectoryError( From f2bfe53de1308d50d645442a03f95500ef8e125c Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 16 Apr 2025 09:33:25 -0400 Subject: [PATCH 07/12] move test servers to root tests dir --- tests/{server => }/test_servers/fastmcp_server.py | 0 tests/{server => }/test_servers/sse.py | 0 tests/{server => }/test_servers/stdio.py | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename tests/{server => }/test_servers/fastmcp_server.py (100%) rename tests/{server => }/test_servers/sse.py (100%) rename tests/{server => }/test_servers/stdio.py (100%) diff --git a/tests/server/test_servers/fastmcp_server.py b/tests/test_servers/fastmcp_server.py similarity index 100% rename from tests/server/test_servers/fastmcp_server.py rename to tests/test_servers/fastmcp_server.py diff --git a/tests/server/test_servers/sse.py b/tests/test_servers/sse.py similarity index 100% rename from tests/server/test_servers/sse.py rename to tests/test_servers/sse.py diff --git a/tests/server/test_servers/stdio.py b/tests/test_servers/stdio.py similarity index 100% rename from tests/server/test_servers/stdio.py rename to tests/test_servers/stdio.py From 0fe09ad0b08edb10b27f73f19c2b82893603c327 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 16 Apr 2025 09:33:34 -0400 Subject: [PATCH 08/12] default sse to 127.0.0.1 --- src/fastmcp/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index 41b3fcb89..98ae67376 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -42,7 +42,7 @@ class ServerSettings(BaseSettings): log_level: LOG_LEVEL = Field(default_factory=lambda: Settings().log_level) # HTTP settings - host: str = "0.0.0.0" + host: str = "127.0.0.1" port: int = 8000 sse_path: str = "/sse" message_path: str = "/messages/" From b4c53c9f883deb58d0b298b3a11c8b15d9ec5a8f Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 16 Apr 2025 09:34:59 -0400 Subject: [PATCH 09/12] Fix static checks --- src/fastmcp/client/transports.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index c3c2b797b..cea14a0b9 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -2,12 +2,13 @@ import abc import contextlib import datetime import os +import shutil from collections.abc import AsyncIterator from pathlib import Path from typing import ( TypedDict, ) -import shutil + from mcp import ClientSession, StdioServerParameters from mcp.client.session import ( ListRootsFnT, From 48ee1597e224ce9e218806c1b023a177dd253909 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 16 Apr 2025 10:25:31 -0400 Subject: [PATCH 10/12] Improve integration documentation --- docs/patterns/fastapi.mdx | 176 ++++++++++++------------ docs/patterns/openapi.mdx | 257 ++++++++++++++++------------------- pyproject.toml | 15 +- src/fastmcp/server/server.py | 4 +- uv.lock | 8 +- 5 files changed, 224 insertions(+), 236 deletions(-) diff --git a/docs/patterns/fastapi.mdx b/docs/patterns/fastapi.mdx index 45e9d2e78..1bbfd6231 100644 --- a/docs/patterns/fastapi.mdx +++ b/docs/patterns/fastapi.mdx @@ -1,114 +1,120 @@ --- title: FastAPI Integration sidebarTitle: FastAPI -description: Automatically create FastMCP servers directly from FastAPI applications. +description: Generate MCP servers from FastAPI apps icon: square-bolt --- -If you build your APIs using the popular [FastAPI](https://fastapi.tiangolo.com/) framework, FastMCP offers a seamless way to expose your FastAPI application as an MCP server. This leverages the OpenAPI integration internally but simplifies the setup significantly. -## The Goal: FastAPI App -> MCP Server +FastMCP can automatically convert FastAPI applications into MCP servers. -FastAPI automatically generates an OpenAPI specification for your application. FastMCP uses this built-in capability to create an MCP server that mirrors your API routes. + +FastMCP does *not* include FastAPI as a dependency; you must install it separately to run these examples. + -- FastAPI path operations (`@app.get`, `@app.post`, etc.) become MCP tools, resources, or templates. -- Pydantic models used in FastAPI for request/response validation are used to generate MCP schemas. -- Communication happens directly in memory, making it very efficient. -## Creating from FastAPI App +```python {2, 22, 25} +from fastapi import FastAPI +from fastmcp import FastMCP -Use the `FastMCP.from_fastapi()` class method. You only need your FastAPI `app` instance. + +# A FastAPI app +app = FastAPI() + +@app.get("/items") +def list_items(): + return [{"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}] + +@app.get("/items/{item_id}") +def get_item(item_id: int): + return {"id": item_id, "name": f"Item {item_id}"} + +@app.post("/items") +def create_item(name: str): + return {"id": 3, "name": name} + + +# Create an MCP server from your FastAPI app +mcp = FastMCP.from_fastapi(app=app) + +if __name__ == "__main__": + mcp.run() # Start the MCP server +``` + +## Route Mapping + +By default, FastMCP will map FastAPI routes to MCP components according to the following rules: + +| FastAPI Route Type | FastAPI Example | MCP Component | Notes | +|--------------------|--------------|---------|-------| +| GET without path params | `@app.get("/stats")` | Resource | Simple resources for fetching data | +| GET with path params | `@app.get("/users/{id}")` | Resource Template | Path parameters become template parameters | +| POST, PUT, DELETE, etc. | `@app.post("/users")` | Tool | Operations that modify data | + +For more details on route mapping or custom mapping rules, see the [OpenAPI integration documentation](/patterns/openapi#route-mapping); FastMCP uses the same mapping rules for both FastAPI and OpenAPI integrations. + +## Complete Example + +Here's a more detailed example with a data model: ```python import asyncio -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException from pydantic import BaseModel -from fastmcp import FastMCP, Client # Import FastMCP and Client - -# 1. Define your FastAPI application -api_app = FastAPI(title="MyFastAPIApp") +from fastmcp import FastMCP, Client +# Define your Pydantic model class Item(BaseModel): name: str price: float - is_offer: bool | None = None -@api_app.get("/") -def read_root(): - return {"Hello": "World"} +# Create your FastAPI app +app = FastAPI() +items = {} # In-memory database -@api_app.get("/items/{item_id}") # -> Resource Template -def read_item(item_id: int, q: str | None = None): - # This will become resource://openapi/read_item_items__item_id__get/{item_id} - return {"item_id": item_id, "q": q, "description": f"Details for item {item_id}"} +@app.get("/items") +def list_items(): + """List all items""" + return list(items.values()) -@api_app.post("/items/") # -> Tool +@app.get("/items/{item_id}") +def get_item(item_id: int): + """Get item by ID""" + if item_id not in items: + raise HTTPException(404, "Item not found") + return items[item_id] + +@app.post("/items") def create_item(item: Item): - # This will become the 'create_item_items__post' tool - print(f"Creating item: {item.name}") - return {"item_name": item.name, "status": "created"} + """Create a new item""" + item_id = len(items) + 1 + items[item_id] = {"id": item_id, **item.model_dump()} + return items[item_id] -# 2. Create the FastMCP server directly from the FastAPI app -# This is an async class method -async def create_mcp_server_from_fastapi(): - mcp_server = await FastMCP.from_fastapi( - app=api_app, - name="FastAPI_MCP_Bridge" # Optional name for the MCP server - ) - return mcp_server - -# 3. (Example) Run the MCP server and test with an in-memory client -async def run_and_test(): - server = await create_mcp_server_from_fastapi() - print(f"Created MCP server '{server.name}' from FastAPI app '{api_app.title}'") - - # List discovered components - tools = await server.list_tools() - templates = await server.list_resource_templates() - print("Discovered Tools:", [t.name for t in tools]) - print("Discovered Templates:", [t.uriTemplate for t in templates]) - - # Test using an in-memory client - client = Client(server) # Uses FastMCPTransport - async with client: - # Call the tool derived from POST /items/ - create_result = await client.call_tool( - "create_item_items__post", - {"name": "MCP Special", "price": 99.99} # Pydantic model fields become args - ) - print("Create Item Tool Result:", create_result[0].text) # JSON string - - # Read the resource derived from GET /items/{item_id} - read_result = await client.read_resource( - "resource://openapi/read_item_items__item_id__get/42" # Match template URI - ) - print("Read Item Resource Result:", read_result[0].text) # JSON string - - # In a real scenario, you might run the MCP server via stdio or sse - # print("Running MCP server via stdio...") - # server.run() +# Test your MCP server with a client +async def test(): + # Create MCP server from FastAPI app + mcp = await FastMCP.from_fastapi(app=app) + + # List the components that were created + tools = await mcp.list_tools() + resources = await mcp.list_resources() + templates = await mcp.list_resource_templates() + + print(f"Generated {len(tools)} tools") + print(f"Generated {len(resources)} resources") + print(f"Generated {len(templates)} templates") + + # In a real scenario, you would run the server: + # mcp.run() if __name__ == "__main__": - # Requires fastapi, uvicorn, httpx: - # uv pip install "fastapi[all]" httpx - try: - asyncio.run(run_and_test()) - except ImportError as e: - print(f"Error: {e}. Please install required packages: uv pip install \"fastapi[all]\" httpx") - -# Example Output might include: -# Created MCP server 'FastAPI_MCP_Bridge' from FastAPI app 'MyFastAPIApp' -# Discovered Tools: ['read_root___get', 'create_item_items__post'] -# Discovered Templates: ['resource://openapi/read_item_items__item_id__get/{item_id}'] -# Create Item Tool Result: {"item_name": "MCP Special", "status": "created"} -# Read Item Resource Result: {"item_id": 42, "q": null, "description": "Details for item 42"} + asyncio.run(test()) ``` -### How it Works Internally +## Benefits -1. **OpenAPI Generation**: `from_fastapi` asks the FastAPI `app` for its OpenAPI schema dictionary (`app.openapi()`). -2. **In-Memory Client**: It creates an `httpx.AsyncClient` configured with an `ASGITransport`. This special transport allows `httpx` to call the FastAPI application directly in memory without needing a running web server process. -3. **OpenAPI Integration**: It calls `FastMCP.from_openapi()`, passing the generated schema and the in-memory `httpx` client. -4. **MCP Server Creation**: The standard OpenAPI integration logic then proceeds to parse the schema and create the `Tool`, `Resource`, and `ResourceTemplate` components that wrap calls to the in-memory FastAPI app. - -This provides a highly efficient way to expose your FastAPI logic through the MCP protocol, leveraging FastAPI's routing, dependency injection, and validation features. \ No newline at end of file +- **Leverage existing FastAPI apps** - No need to rewrite your API logic +- **Schema reuse** - FastAPI's Pydantic models and validation are inherited +- **Full feature support** - Works with FastAPI's authentication, dependencies, etc. +- **ASGI transport** - Direct communication without additional HTTP overhead diff --git a/docs/patterns/openapi.mdx b/docs/patterns/openapi.mdx index 1767700c2..c537ac92a 100644 --- a/docs/patterns/openapi.mdx +++ b/docs/patterns/openapi.mdx @@ -1,174 +1,153 @@ --- title: OpenAPI Integration sidebarTitle: OpenAPI -description: Automatically create FastMCP servers from existing OpenAPI specifications. +description: Generate MCP servers from OpenAPI specs icon: code-branch --- -If you have existing REST APIs documented with the OpenAPI Specification (OAS), FastMCP can automatically generate MCP tools, resources, and resource templates directly from that specification. This provides a quick way to make your existing HTTP APIs accessible to MCP clients and LLMs. +FastMCP can automatically generate an MCP server from an OpenAPI specification. Users only need to provide an OpenAPI specification (3.0 or 3.1) and an API client. -FastMCP supports both OpenAPI 3.0 and 3.1 specifications for maximum compatibility with existing API definitions. +```python +import httpx +from fastmcp import FastMCP -## The Goal: API -> MCP Server +# Create a client for your API +api_client = httpx.AsyncClient(base_url="https://api.example.com") -The core idea is to map OpenAPI paths and operations (like `GET /users/{id}` or `POST /orders`) to their corresponding MCP components: +# Load your OpenAPI spec +spec = {...} -- `GET` requests often map to MCP **Resources** (for fetching single items) or **Resource Templates** (if the path has parameters). -- `POST`, `PUT`, `PATCH`, `DELETE` requests typically map to MCP **Tools** (for actions that create or modify data). +# Create an MCP server from your OpenAPI spec +mcp = FastMCP.from_openapi(openapi_spec=spec, client=api_client) -FastMCP automates this mapping process. +if __name__ == "__main__": + mcp.run() +``` -## Creating from OpenAPI Spec +## Route Mapping -Use the `FastMCP.from_openapi()` class method. You need: +By default, OpenAPI routes are mapped to MCP components based on these rules: -1. The OpenAPI specification as a Python dictionary. -2. An `httpx.AsyncClient` configured to make requests to the actual API backend. +| OpenAPI Route | Example |MCP Component | Notes | +|- | - | - | - | +| `GET` without path params | `GET /stats` | Resource | Simple resources for fetching data | +| `GET` with path params | `GET /users/{id}` | Resource Template | Path parameters become template parameters | +| `POST`, `PUT`, `PATCH`, `DELETE`, etc. | `POST /users` | Tool | Operations that modify data | - -```python server.py +Internally, FastMCP uses a priority-ordered set of `RouteMap` objects to determine the component type. Route maps indicate that a specific HTTP method (or methods) and path pattern should be treated as a specific component type. This is the default set of route maps: + +```python +# Simplified version of the actual mapping rules +DEFAULT_ROUTE_MAPPINGS = [ + # GET with path parameters -> ResourceTemplate + RouteMap(methods=["GET"], pattern=r".*\{.*\}.*", + route_type=RouteType.RESOURCE_TEMPLATE), + + # GET without path parameters -> Resource + RouteMap(methods=["GET"], pattern=r".*", + route_type=RouteType.RESOURCE), + + # All other methods -> Tool + RouteMap(methods=["POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"], + pattern=r".*", route_type=RouteType.TOOL), +] +``` + +### Custom Route Maps + +Users can add custom route maps to override the default mapping behavior. User-supplied route maps are always applied first, before the default route maps. + +```python +from fastmcp.server.openapi import RouteMap, RouteType + +# Custom mapping rules +custom_maps = [ + # Force all analytics endpoints to be Tools + RouteMap(methods=["GET"], + pattern=r"^/analytics/.*", + route_type=RouteType.TOOL) +] + +# Apply custom mappings +mcp = await FastMCP.from_openapi( + openapi_spec=spec, + client=api_client, + route_maps=custom_maps +) +``` + +## How It Works + +1. FastMCP parses your OpenAPI spec to extract routes and schemas +2. It applies mapping rules to categorize each route +3. When an MCP client calls a tool or accesses a resource: + - FastMCP constructs an HTTP request based on the OpenAPI definition + - It sends the request through the provided httpx client + - It translates the HTTP response to the appropriate MCP format + +## Complete Example + +```python import asyncio import httpx from fastmcp import FastMCP -# load the OpenAPI specification from the openapi_spec.py file -petstore_spec = PETSTORE_SPEC - -# Client to communicate with the actual Pet Store API backend -# The base_url should match the server URL in the OpenAPI spec -http_client = httpx.AsyncClient(base_url="http://petstore.example.com/api") - -# Create the FastMCP server from the spec -# This is an async class method -async def create_openapi_server(): - mcp_server = await FastMCP.from_openapi( - openapi_spec=petstore_spec, - client=http_client, - name="PetStoreMCP" # Optional name for the MCP server - ) - return mcp_server - -async def run_server(): - server = await create_openapi_server() - print(f"Starting OpenAPI-based server '{server.name}'...") - - # List discovered components - tools = await server.list_tools() - resources = await server.list_resources() - templates = await server.list_resource_templates() - print("Discovered Tools:", [t.name for t in tools]) - print("Discovered Resources:", [r.uri for r in resources]) # Should be empty if no parameterless GETs - print("Discovered Templates:", [t.uriTemplate for t in templates]) - - # Run the server (e.g., via stdio) - # server.run() - -if __name__ == "__main__": - # Example: Create the server and print discovered components - # Requires httpx: uv pip install httpx - asyncio.run(run_server()) - -# Expected Output might include: -# Discovered Tools: ['listPets', 'createPet'] -# Discovered Resources: [] -# Discovered Templates: ['resource://openapi/showPetById/{petId}'] -``` - -```python openapi_spec.py -# Example OpenAPI Specification (simplified Pet Store) -PETSTORE_SPEC = { - "openapi": "3.1.0", - "info": {"title": "Simple Pet Store", "version": "1.0.0"}, - "servers": [{"url": "http://petstore.example.com/api"}], # Base URL for API calls +# Sample OpenAPI spec for a Pet Store API +petstore_spec = { + "openapi": "3.0.0", "paths": { "/pets": { "get": { - "summary": "List all pets", "operationId": "listPets", - "tags": ["pets"], - "parameters": [{ # Query parameter -> Tool argument - "name": "limit", "in": "query", "schema": {"type": "integer"} - }], - "responses": {"200": {"description": "A list of pets."}}, + "summary": "List all pets" }, - "post": { # POST -> Tool - "summary": "Create a pet", + "post": { "operationId": "createPet", - "tags": ["pets"], - "requestBody": { # Request body -> Tool arguments - "required": True, - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PetInput"}}} - }, - "responses": {"201": {"description": "Pet created."}}, - }, + "summary": "Create a new pet" + } }, - "/pets/{petId}": { # Path parameter -> Resource Template - "get": { # GET with path param -> Resource Template / FunctionResource - "summary": "Info for a specific pet", - "operationId": "showPetById", - "tags": ["pets"], - "parameters": [{ # Path parameter -> Template function argument - "name": "petId", "in": "path", "required": True, "schema": {"type": "string"} - }], - "responses": {"200": {"description": "Information about the pet."}}, - }, - }, - }, - "components": { - "schemas": { - "PetInput": {"type": "object", "properties": {"name": {"type": "string"}, "tag": {"type": "string"}}}, + "/pets/{petId}": { + "get": { + "operationId": "getPet", + "summary": "Get a pet by ID", + "parameters": [ + { + "name": "petId", + "in": "path", + "required": True, + "schema": {"type": "string"} + } + ] + } } } } -``` - - -### How it Works Internally - -1. **Parsing**: `from_openapi` parses the spec using utilities that leverage `openapi-pydantic`. It extracts paths, operations, parameters, request bodies, and responses. -2. **Mapping**: It applies mapping rules (see below) to decide whether each OpenAPI operation (`GET /pets`, `POST /pets`, `GET /pets/{petId}`) becomes an MCP `Tool`, `Resource`, or `ResourceTemplate`. -3. **Component Creation**: It creates specialized internal components (`OpenAPITool`, `OpenAPIResource`, `OpenAPIResourceTemplate`). -4. **HTTP Execution**: When an MCP client calls a tool or reads a resource from this server: - * The corresponding OpenAPI component constructs an HTTP request based on the OpenAPI definition and the arguments provided by the MCP client. - * It uses the provided `httpx.AsyncClient` to send the request to the backend API. - * It processes the HTTP response and returns it to the MCP client in the appropriate MCP format. -5. **Schema Generation**: The schemas for MCP tools are derived by combining OpenAPI parameters (path, query, header) and request body schemas. Resource template function arguments are derived from path parameters. -6. **Descriptions**: Tool/Resource descriptions are enhanced with information from OpenAPI responses to give the LLM more context about potential outcomes. - -### Default Mapping Rules - -FastMCP uses the following default rules to map OpenAPI operations: - -- `GET` operation with path parameters (e.g., `/users/{id}`) -> **`ResourceTemplate`** -- `GET` operation without path parameters (e.g., `/users`) -> **`Resource`** -- `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `HEAD` -> **`Tool`** - -### Customize Route Mapping - -You can customize the mapping rules by providing a list of `RouteMap` objects directly to `FastMCP.from_openapi()` using the `route_maps` parameter: - -```python -from fastmcp.server.openapi import RouteMap, RouteType -from fastmcp import FastMCP - -# Custom mapping: Treat GET /admin/stats as a Tool, not a Resource -custom_maps = [ - RouteMap(methods=["GET"], pattern=r"^/admin/stats$", route_type=RouteType.TOOL) -] - -async def create_server_with_custom_mapping(): - mcp_server = await FastMCP.from_openapi( +async def main(): + # Client for the Pet Store API + client = httpx.AsyncClient(base_url="https://petstore.example.com/api") + + # Create the MCP server + mcp = await FastMCP.from_openapi( openapi_spec=petstore_spec, - client=http_client, - name="PetStoreMCP", - route_maps=custom_maps # Pass custom mapping rules + client=client, + name="PetStore" ) - return mcp_server + + # List what components were created + tools = await mcp.list_tools() + resources = await mcp.list_resources() + templates = await mcp.list_resource_templates() + + print(f"Tools: {len(tools)}") # Should include createPet + print(f"Resources: {len(resources)}") # Should include listPets + print(f"Templates: {len(templates)}") # Should include getPet + + # Start the MCP server + mcp.run() + +if __name__ == "__main__": + asyncio.run(main()) ``` -Each `RouteMap` maps one or more HTTP methods and a regular expression pattern for the route path to an MCP `RouteType`. Route maps are processed in order, and the first match wins. - -All parameters passed to `FastMCP.from_openapi()` will be forwarded to the underlying `FastMCPOpenAPI` constructor, so you can customize any aspect of the OpenAPI integration directly through this method call. - diff --git a/pyproject.toml b/pyproject.toml index a2a2297ed..0943452cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,13 +5,13 @@ description = "The fast, Pythonic way to build MCP servers." authors = [{ name = "Jeremiah Lowin" }] dependencies = [ "dotenv>=0.9.9", + "exceptiongroup>=1.2.2", + "httpx>=0.28.1", "mcp>=1.6.0,<2.0.0", + "openapi-pydantic>=0.5.1", "rich>=13.9.4", "typer>=0.15.2", "websockets>=15.0.1", - "fastapi>=0.115.12", - "openapi-pydantic>=0.5.1", - "exceptiongroup>=1.2.2", ] requires-python = ">=3.10" readme = "README.md" @@ -38,6 +38,11 @@ classifiers = [ [dependency-groups] dev = [ + "copychat>=0.5.2", + "dirty-equals>=0.9.0", + "fastapi>=0.115.12", + "ipython>=8.12.3", + "pdbpp>=0.10.3", "pre-commit", "pyright>=1.1.389", "pytest>=8.3.3", @@ -45,10 +50,6 @@ dev = [ "pytest-flakefinder", "pytest-xdist>=3.6.1", "ruff", - "copychat>=0.5.2", - "ipython>=8.12.3", - "pdbpp>=0.10.3", - "dirty-equals>=0.9.0", ] [project.scripts] diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index facd5ff18..8fd15d755 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -13,7 +13,6 @@ import anyio import httpx import pydantic_core import uvicorn -from fastapi import FastAPI from mcp.server.lowlevel.helper_types import ReadResourceContents from mcp.server.lowlevel.server import LifespanResultT from mcp.server.lowlevel.server import Server as MCPServer @@ -846,11 +845,12 @@ class FastMCP(Generic[LifespanResultT]): @classmethod def from_fastapi( - cls, app: FastAPI, name: str | None = None, **settings: Any + cls, app: "Any", name: str | None = None, **settings: Any ) -> "FastMCPOpenAPI": """ Create a FastMCP server from a FastAPI application. """ + from .openapi import FastMCPOpenAPI client = httpx.AsyncClient( diff --git a/uv.lock b/uv.lock index 6fb683270..a12924537 100644 --- a/uv.lock +++ b/uv.lock @@ -254,12 +254,12 @@ wheels = [ [[package]] name = "fastmcp" -version = "2.1.3.dev31+8d922d9" +version = "2.1.3.dev42+be2ccc6" source = { editable = "." } dependencies = [ { name = "dotenv" }, { name = "exceptiongroup" }, - { name = "fastapi" }, + { name = "httpx" }, { name = "mcp" }, { name = "openapi-pydantic" }, { name = "rich" }, @@ -271,6 +271,7 @@ dependencies = [ dev = [ { name = "copychat" }, { name = "dirty-equals" }, + { name = "fastapi" }, { name = "ipython" }, { name = "pdbpp" }, { name = "pre-commit" }, @@ -286,7 +287,7 @@ dev = [ requires-dist = [ { name = "dotenv", specifier = ">=0.9.9" }, { name = "exceptiongroup", specifier = ">=1.2.2" }, - { name = "fastapi", specifier = ">=0.115.12" }, + { name = "httpx", specifier = ">=0.28.1" }, { name = "mcp", specifier = ">=1.6.0,<2.0.0" }, { name = "openapi-pydantic", specifier = ">=0.5.1" }, { name = "rich", specifier = ">=13.9.4" }, @@ -298,6 +299,7 @@ requires-dist = [ dev = [ { name = "copychat", specifier = ">=0.5.2" }, { name = "dirty-equals", specifier = ">=0.9.0" }, + { name = "fastapi", specifier = ">=0.115.12" }, { name = "ipython", specifier = ">=8.12.3" }, { name = "pdbpp", specifier = ">=0.10.3" }, { name = "pre-commit" }, From eecd4212aba3a3cc55f538afa53a71f52db00b0f Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 16 Apr 2025 10:56:30 -0400 Subject: [PATCH 11/12] Improve documentation --- docs/clients/{overview.mdx => client.mdx} | 4 + docs/docs.json | 4 +- docs/patterns/composition.mdx | 117 +++++++++-------- docs/patterns/fastapi.mdx | 3 + docs/patterns/openapi.mdx | 3 + docs/patterns/{proxying.mdx => proxy.mdx} | 3 + docs/servers/context.mdx | 3 + docs/servers/fastmcp.mdx | 152 +++------------------- docs/snippets/version-badge.mdx | 8 ++ docs/style.css | 44 ++++++- 10 files changed, 149 insertions(+), 192 deletions(-) rename docs/clients/{overview.mdx => client.mdx} (99%) rename docs/patterns/{proxying.mdx => proxy.mdx} (98%) create mode 100644 docs/snippets/version-badge.mdx diff --git a/docs/clients/overview.mdx b/docs/clients/client.mdx similarity index 99% rename from docs/clients/overview.mdx rename to docs/clients/client.mdx index 5a381ff68..479120ce6 100644 --- a/docs/clients/overview.mdx +++ b/docs/clients/client.mdx @@ -5,6 +5,10 @@ description: Learn how to use the FastMCP Client to interact with MCP servers. icon: user-robot --- +import { VersionBadge } from '/snippets/version-badge.mdx' + + + The `fastmcp.Client` provides a high-level, asynchronous interface for interacting with any Model Context Protocol (MCP) server, whether it's built with FastMCP or another implementation. It simplifies communication by handling protocol details and connection management. ## FastMCP Client diff --git a/docs/docs.json b/docs/docs.json index dc7d9197d..237dee35b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -50,14 +50,14 @@ { "group": "Clients", "pages": [ - "clients/overview", + "clients/client", "clients/transports" ] }, { "group": "Patterns", "pages": [ - "patterns/proxying", + "patterns/proxy", "patterns/composition", "patterns/decorating-methods", "patterns/openapi", diff --git a/docs/patterns/composition.mdx b/docs/patterns/composition.mdx index d00522595..6d620954f 100644 --- a/docs/patterns/composition.mdx +++ b/docs/patterns/composition.mdx @@ -4,6 +4,9 @@ sidebarTitle: Composition description: Combine multiple FastMCP servers into a single, larger application using mounting and importing. icon: puzzle-piece --- +import { VersionBadge } from '/snippets/version-badge.mdx' + + As your MCP applications grow, you might want to organize your tools, resources, and prompts into logical modules or reuse existing server components. FastMCP supports composition through two methods: @@ -17,13 +20,31 @@ As your MCP applications grow, you might want to organize your tools, resources, - **Teamwork**: Different teams can work on separate FastMCP servers that are later combined. - **Organization**: Keep related functionality grouped together logically. -## Importing Subservers (Static Composition) +### Importing vs Mounting + +The choice of importing or mounting depends on your use case and requirements. In general, importing is best for simpler cases because it copies the imported server's components into the main server, treating them as native integrations. Mounting is best for more complex cases where you need to delegate requests to the subserver at runtime. + + +| Feature | Importing | Mounting | +|---------|----------------|---------| +| **Method** | `FastMCP.import_server()` | `FastMCP.mount()` | +| **Composition Type** | One-time copy (static) | Live link (dynamic) | +| **Updates** | Changes to subserver NOT reflected | Changes to subserver immediately reflected | +| **Lifespan** | Not managed | Automatically managed | +| **Synchronicity** | Async (must be awaited) | Sync | +| **Best For** | Bundling finalized components | Modular runtime composition | + +### Proxy Servers + +FastMCP supports [MCP proxying](/patterns/proxy), which allows you to mirror a local or remote server in a local FastMCP instance. Proxies are fully compatible with both importing and mounting. + + +## Importing (Static Composition) The `import_server()` method copies all components (tools, resources, templates, prompts) from one `FastMCP` instance (the *subserver*) into another (the *main server*). A `prefix` is added to avoid naming conflicts. ```python from fastmcp import FastMCP -from typing import dict, list import asyncio # --- Define Subservers --- @@ -114,7 +135,7 @@ await main_mcp.import_server( Be cautious when choosing separators. Some MCP clients (like Claude Desktop) might have restrictions on characters allowed in tool names (e.g., `/` might not be supported). The defaults (`_` for names, `+` for URIs) are generally safe. -## Mounting Subservers (Live Linking) +## Mounting (Live Linking) The `mount()` method creates a **live link** between the `main_mcp` server and the `subserver`. Instead of copying components, requests for components matching the `prefix` are **delegated** to the `subserver` at runtime. @@ -186,61 +207,19 @@ main_mcp.mount( ) ``` -## Comparing Import and Mount - -| Feature | `import_server` | `mount` | -|---------|----------------|---------| -| **Synchronicity** | Async (must be awaited) | Sync | -| **Composition Type** | One-time copy (static) | Live link (dynamic) | -| **Updates** | Changes to subserver NOT reflected | Changes to subserver immediately reflected | -| **Lifespan** | Not managed | Automatically managed | -| **Best For** | Bundling finalized components | Modular runtime composition | ## Example: Modular Application Here's how a modular application might use `import_server`: -```python -# modules/text_utils.py -from fastmcp import FastMCP -from typing import list - -text_mcp = FastMCP(name="TextUtilities") - -@text_mcp.tool() -def count_words(text: str) -> int: - """Counts words in a text.""" - return len(text.split()) - -@text_mcp.resource("resource://stopwords") -def get_stopwords() -> list[str]: - """Return a list of common stopwords.""" - return ["the", "a", "is", "in"] - -# ------------------------------ -# modules/data_api.py -from fastmcp import FastMCP -import random -from typing import dict - -data_mcp = FastMCP(name="DataAPI") - -@data_mcp.tool() -def fetch_record(record_id: int) -> dict: - """Fetches a dummy data record.""" - return {"id": record_id, "value": random.random()} - -@data_mcp.resource("data://schema/{table}") -def get_table_schema(table: str) -> dict: - """Provides a dummy schema for a table.""" - return {"table": table, "columns": ["id", "value"]} - -# ------------------------------ -# main_app.py + +```python main.py from fastmcp import FastMCP import asyncio -from modules.text_utils import text_mcp # Import server instances -from modules.data_api import data_mcp + +# Import the servers (see other files) +from modules.text_server import text_mcp +from modules.data_server import data_mcp app = FastMCP(name="MainApplication") @@ -273,8 +252,42 @@ if __name__ == "__main__": # Run the server app.run() ``` +```python modules/text_server.py +from fastmcp import FastMCP -Now, running `main_app.py` starts a server that exposes: +text_mcp = FastMCP(name="TextUtilities") + +@text_mcp.tool() +def count_words(text: str) -> int: + """Counts words in a text.""" + return len(text.split()) + +@text_mcp.resource("resource://stopwords") +def get_stopwords() -> list[str]: + """Return a list of common stopwords.""" + return ["the", "a", "is", "in"] +``` + +```python modules/data_server.py +from fastmcp import FastMCP +import random +from typing import dict + +data_mcp = FastMCP(name="DataAPI") + +@data_mcp.tool() +def fetch_record(record_id: int) -> dict: + """Fetches a dummy data record.""" + return {"id": record_id, "value": random.random()} + +@data_mcp.resource("data://schema/{table}") +def get_table_schema(table: str) -> dict: + """Provides a dummy schema for a table.""" + return {"table": table, "columns": ["id", "value"]} +``` + + +Now, running `main.py` starts a server that exposes: - `text_count_words` - `data_fetch_record` - `process_and_analyze` diff --git a/docs/patterns/fastapi.mdx b/docs/patterns/fastapi.mdx index 1bbfd6231..a5501a225 100644 --- a/docs/patterns/fastapi.mdx +++ b/docs/patterns/fastapi.mdx @@ -4,6 +4,9 @@ sidebarTitle: FastAPI description: Generate MCP servers from FastAPI apps icon: square-bolt --- +import { VersionBadge } from '/snippets/version-badge.mdx' + + FastMCP can automatically convert FastAPI applications into MCP servers. diff --git a/docs/patterns/openapi.mdx b/docs/patterns/openapi.mdx index c537ac92a..ca8df1983 100644 --- a/docs/patterns/openapi.mdx +++ b/docs/patterns/openapi.mdx @@ -4,6 +4,9 @@ sidebarTitle: OpenAPI description: Generate MCP servers from OpenAPI specs icon: code-branch --- +import { VersionBadge } from '/snippets/version-badge.mdx' + + FastMCP can automatically generate an MCP server from an OpenAPI specification. Users only need to provide an OpenAPI specification (3.0 or 3.1) and an API client. diff --git a/docs/patterns/proxying.mdx b/docs/patterns/proxy.mdx similarity index 98% rename from docs/patterns/proxying.mdx rename to docs/patterns/proxy.mdx index e0204446e..412cbfd6e 100644 --- a/docs/patterns/proxying.mdx +++ b/docs/patterns/proxy.mdx @@ -4,6 +4,9 @@ sidebarTitle: Proxying description: Use FastMCP to act as an intermediary or change transport for other MCP servers. icon: arrows-retweet --- +import { VersionBadge } from '/snippets/version-badge.mdx' + + FastMCP provides a powerful proxying capability that allows one FastMCP server instance to act as a frontend for another MCP server (which could be remote, running on a different transport, or even another FastMCP instance). This is achieved using the `FastMCP.from_client()` class method. diff --git a/docs/servers/context.mdx b/docs/servers/context.mdx index ea0880097..219dcd37a 100644 --- a/docs/servers/context.mdx +++ b/docs/servers/context.mdx @@ -4,6 +4,7 @@ sidebarTitle: Context description: Access MCP capabilities like logging, progress, and resources within your tools. icon: rectangle-code --- +import { VersionBadge } from '/snippets/version-badge.mdx' When defining FastMCP [tools](/servers/tools), your functions might need to interact with the underlying MCP session or access server capabilities. FastMCP provides the `Context` object for this purpose. @@ -174,6 +175,8 @@ The returned content is typically accessed via `content_list[0].content` and can ### LLM Sampling + + Request the client's LLM to generate text based on provided messages. This is useful when your tool needs to leverage the LLM's capabilities to process data or generate responses. ```python diff --git a/docs/servers/fastmcp.mdx b/docs/servers/fastmcp.mdx index d07cc219d..745d56bdf 100644 --- a/docs/servers/fastmcp.mdx +++ b/docs/servers/fastmcp.mdx @@ -225,159 +225,39 @@ The CLI can dynamically find and run FastMCP server objects in your files, but i ## Composing Servers -FastMCP provides two methods for composing multiple servers together: +FastMCP supports composing multiple servers together using `import_server` (static copy) and `mount` (live link). This allows you to organize large applications into modular components or reuse existing servers. -1. `import_server()`: One-time static import of components (async) -2. `mount()`: Live link delegating to subservers (sync) - -This allows you to organize large applications into logical components, reuse existing FastMCP servers, and create domain-specific servers that can be used independently or composed. - -### Importing Subservers (Static Composition) - -The `import_server()` method performs a one-time copy of all components from a subserver into the main server with prefixed names: +See the [Server Composition](/patterns/composition) guide for full details, best practices, and examples. ```python +# Example: Importing a subserver from fastmcp import FastMCP import asyncio -# Create the main server -main_mcp = FastMCP(name="MainServer") +main = FastMCP(name="Main") +sub = FastMCP(name="Sub") -# Create a domain-specific subserver -weather_mcp = FastMCP(name="WeatherService") +@sub.tool() +def hello(): + return "hi" -@weather_mcp.tool() -def get_forecast(city: str) -> dict: - """Get the weather forecast for a city.""" - return {"city": city, "forecast": "Sunny", "temperature": 72} - -# Create another domain-specific subserver -calculator_mcp = FastMCP(name="CalculatorService") - -@calculator_mcp.tool() -def add(a: float, b: float) -> float: - """Add two numbers.""" - return a + b - -# Import the subservers with prefixes -async def setup(): - await main_mcp.import_server("weather", weather_mcp) - await main_mcp.import_server("calc", calculator_mcp) - -# Now main_mcp has access to both subservers' tools: -# - "weather_get_forecast" (from weather_mcp) -# - "calc_add" (from calculator_mcp) - -if __name__ == "__main__": - # Run async setup - asyncio.run(setup()) - # Then run the server - main_mcp.run() +main.mount("sub", sub) ``` -#### How Import Works +## Proxying Servers -When you import a server with `await main_mcp.import_server(prefix, subserver)`: +FastMCP can act as a proxy for any MCP server (local or remote) using `FastMCP.from_client`, letting you bridge transports or add a frontend to existing servers. For example, you can expose a remote SSE server locally via stdio, or vice versa. -1. All tools from the subserver are copied with prefixed names: - - `tool_name` becomes `{prefix}_tool_name` - - Default separator is `_`, but can be customized - -2. All resources and resource templates are copied with prefixed URIs: - - `resource://data` becomes `{prefix}+resource://data` - - Default separator is `+`, but can be customized - -3. All prompts are copied with prefixed names: - - `prompt_name` becomes `{prefix}_prompt_name` - - Default separator is `_`, but can be customized - -4. This is a **one-time copy** - changes to the subserver after importing won't be reflected in the main server - -5. The subserver's lifespan is **not** managed by the main server - -### Mounting Subservers (Live Linking) - -The `mount()` method creates a live link between servers, delegating requests to the appropriate subserver: +See the [Proxying Servers](/patterns/proxy) guide for details and advanced usage. ```python -from fastmcp import FastMCP +from fastmcp import FastMCP, Client -# Create the main server -main_mcp = FastMCP(name="MainServer") - -# Create a domain-specific subserver -weather_mcp = FastMCP(name="WeatherService") - -@weather_mcp.tool() -def get_forecast(city: str) -> dict: - """Get the weather forecast for a city.""" - return {"city": city, "forecast": "Sunny", "temperature": 72} - -# Mount the subserver (sync operation) -main_mcp.mount("weather", weather_mcp) - -# Later, add another tool to the subserver -@weather_mcp.tool() -def get_temperature(city: str) -> float: - """Get the current temperature for a city.""" - return 72.5 # Example value - -# The new tool is automatically available through the main server -# as "weather_get_temperature" - -if __name__ == "__main__": - main_mcp.run() +backend = Client("http://example.com/mcp/sse") +proxy = FastMCP.from_client(backend, name="ProxyServer") +# Now use the proxy like any FastMCP server ``` -#### How Mounting Works - -When you mount a server with `main_mcp.mount(prefix, subserver)`: - -1. A live link is created between the main server and the subserver -2. Requests for components matching the prefix are delegated to the subserver -3. Changes to the subserver are **immediately reflected** when accessing through the main server -4. The subserver's lifespan **is automatically managed** by the main server - -### Customizing Separators - -For both `import_server()` and `mount()`, you can customize the separators used for naming: - -```python -# For import_server (async) -await main_mcp.import_server( - "weather", - weather_mcp, - tool_separator="-", # Use "weather-get_forecast" instead of "weather_get_forecast" - resource_separator=".", # Use "weather.resource://data" instead of "weather+resource://data" - prompt_separator=":" # Use "weather:prompt_name" instead of "weather_prompt_name" -) - -# For mount (sync) -main_mcp.mount( - "weather", - weather_mcp, - tool_separator="-", - resource_separator=".", - prompt_separator=":" -) -``` - - -Some MCP clients may reject certain separators as invalid. For example, Claude Desktop does not support `/` in tool names. - - -### Comparison - -| Feature | `import_server()` | `mount()` | -|---------|------------------|-----------| -| **Synchronicity** | Async (must be awaited) | Sync | -| **Composition Type** | One-time copy (static) | Live link (dynamic) | -| **Updates** | Changes to subserver NOT reflected | Changes immediately reflected | -| **Lifespan** | Not managed | Automatically managed | -| **Best For** | Bundling finalized components | Modular runtime composition | - -For more detailed examples, see the [Server Composition](/patterns/composition) guide. - ## Server Configuration Server behavior, like transport settings (host, port for SSE) and how duplicate components are handled, can be configured via `ServerSettings`. These settings can be passed during `FastMCP` initialization, set via environment variables (prefixed with `FASTMCP_SERVER_`), or loaded from a `.env` file. diff --git a/docs/snippets/version-badge.mdx b/docs/snippets/version-badge.mdx new file mode 100644 index 000000000..a5021de94 --- /dev/null +++ b/docs/snippets/version-badge.mdx @@ -0,0 +1,8 @@ +export const VersionBadge = ({ version }) => { + return ( + + + New in version {version} + + ); +}; \ No newline at end of file diff --git a/docs/style.css b/docs/style.css index 08c49042a..f6bb832bc 100644 --- a/docs/style.css +++ b/docs/style.css @@ -1,4 +1,4 @@ -/* Target only inline code elements, not code blocks */ +/* Code highlighting -- target only inline code elements, not code blocks */ p code:not(pre code), table code:not(pre code), li code:not(pre code), @@ -9,5 +9,45 @@ h4 code:not(pre code), h5 code:not(pre code), h6 code:not(pre code) { color: #f72585 !important; - background-color: #ea54551a !important; + background-color: rgba(247, 37, 133, 0.09); +} + +/* Version badge -- display a badge with the current version of the documentation */ +.version-badge { + display: inline-flex; + align-items: center; + gap: 0.3em; + padding: 0.32em 1em; + font-size: 0.92em; + font-weight: 600; + letter-spacing: 0.01em; + color: #7417e5; + background: #f3e8ff; + border: 1.5px solid #c084fc; + border-radius: 6px; + box-shadow: none; + vertical-align: middle; + position: relative; + transition: box-shadow 0.2s, transform 0.15s; +} + +.version-badge:hover { + box-shadow: 0 2px 8px 0 rgba(160, 132, 252, 0.1); + transform: translateY(-1px) scale(1.03); +} + +.dark .version-badge { + color: #fff; + background: #312e81; + border: 1.5px solid #a78bfa; +} + +.badge-emoji { + font-size: 1.15em; + line-height: 1; + text-shadow: 0 1px 2px #fff, 0 0px 2px #c084fc; +} + +.dark .badge-emoji { + text-shadow: 0 1px 2px #312e81, 0 0px 2px #a78bfa; } From 28315a012713626b922bf25d5aa890397a4877cd Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 16 Apr 2025 10:57:18 -0400 Subject: [PATCH 12/12] Fix broken links --- docs/clients/transports.mdx | 2 +- docs/servers/context.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/clients/transports.mdx b/docs/clients/transports.mdx index b86ca3d65..a64fb0c25 100644 --- a/docs/clients/transports.mdx +++ b/docs/clients/transports.mdx @@ -7,7 +7,7 @@ icon: link The FastMCP `Client` relies on a `ClientTransport` object to handle the specifics of connecting to and communicating with an MCP server. FastMCP provides several built-in transport implementations for common connection methods. -While the `Client` often infers the correct transport automatically (see [Client Overview](/clients/overview#transport-inference)), you can also instantiate transports explicitly for more control. +While the `Client` often infers the correct transport automatically (see [Client Overview](/clients/client#transport-inference)), you can also instantiate transports explicitly for more control. ## Stdio Transports diff --git a/docs/servers/context.mdx b/docs/servers/context.mdx index 219dcd37a..e6ce5a56f 100644 --- a/docs/servers/context.mdx +++ b/docs/servers/context.mdx @@ -230,7 +230,7 @@ async def generate_example(concept: str, ctx: Context) -> str: return f"```python\n{code_example}\n```" ``` -See [Client Sampling](/clients/overview#llm-sampling) for more details on how clients handle these requests. +See [Client Sampling](/clients/client#llm-sampling) for more details on how clients handle these requests. ### Request Information