From 9a2d92b46dfd7918d13e955a16c687c596804093 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 17 May 2025 12:23:48 -0400 Subject: [PATCH 001/114] Add as_proxy improvements and tests --- README.md | 2 +- docs/servers/composition.mdx | 4 +-- docs/servers/fastmcp.mdx | 4 +-- docs/servers/proxy.mdx | 47 ++++++++++++----------------- examples/in_memory_proxy_example.py | 23 +++++--------- src/fastmcp/server/server.py | 39 ++++++++++++++++++++++-- tests/server/test_import_server.py | 8 ++--- tests/server/test_mount.py | 14 ++++----- tests/server/test_proxy.py | 25 ++++++++++++++- tests/test_deprecated.py | 9 +++++- 10 files changed, 113 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index cf647b881..8dd3787fd 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,7 @@ FastMCP introduces powerful ways to structure and deploy your MCP applications. ### Proxy Servers -Create a FastMCP server that acts as an intermediary for another local or remote MCP server using `FastMCP.from_client()`. This is especially useful for bridging transports (e.g., remote SSE to local Stdio) or adding a layer of logic to a server you don't control. +Create a FastMCP server that acts as an intermediary for another local or remote MCP server using `FastMCP.as_proxy()`. This is especially useful for bridging transports (e.g., remote SSE to local Stdio) or adding a layer of logic to a server you don't control. Learn more in the [**Proxying Documentation**](https://gofastmcp.com/patterns/proxy). diff --git a/docs/servers/composition.mdx b/docs/servers/composition.mdx index 511c0e95d..7f14f0609 100644 --- a/docs/servers/composition.mdx +++ b/docs/servers/composition.mdx @@ -167,11 +167,11 @@ FastMCP automatically uses proxy mounting when the mounted server has a custom l #### Interaction with Proxy Servers -When using `FastMCP.from_client()` to create a proxy server, mounting that server will always use proxy mounting: +When using `FastMCP.as_proxy()` to create a proxy server, mounting that server will always use proxy mounting: ```python # Create a proxy for a remote server -remote_proxy = FastMCP.from_client(Client("http://example.com/mcp")) +remote_proxy = FastMCP.as_proxy(Client("http://example.com/mcp")) # Mount the proxy (always uses proxy mounting) main_server.mount("remote", remote_proxy) diff --git a/docs/servers/fastmcp.mdx b/docs/servers/fastmcp.mdx index 2e5e18ef2..ad2a356b0 100644 --- a/docs/servers/fastmcp.mdx +++ b/docs/servers/fastmcp.mdx @@ -156,7 +156,7 @@ main.mount("sub", sub) -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. +FastMCP can act as a proxy for any MCP server (local or remote) using `FastMCP.as_proxy`, 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. See the [Proxying Servers](/patterns/proxy) guide for details and advanced usage. @@ -164,7 +164,7 @@ See the [Proxying Servers](/patterns/proxy) guide for details and advanced usage from fastmcp import FastMCP, Client backend = Client("http://example.com/mcp/sse") -proxy = FastMCP.from_client(backend, name="ProxyServer") +proxy = FastMCP.as_proxy(backend, name="ProxyServer") # Now use the proxy like any FastMCP server ``` diff --git a/docs/servers/proxy.mdx b/docs/servers/proxy.mdx index 9743b629a..f755b8ead 100644 --- a/docs/servers/proxy.mdx +++ b/docs/servers/proxy.mdx @@ -8,7 +8,9 @@ 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. +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. + +`as_proxy()` accepts either an existing `Client` or any argument that can be passed to a `Client` as its `transport` parameter—such as another `FastMCP` instance or a URL to a remote server. ## What is Proxying? @@ -37,26 +39,23 @@ sequenceDiagram ## Creating a Proxy -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. +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. ```python -from fastmcp import FastMCP, Client +from fastmcp import FastMCP -# Create a client configured to talk to the backend server -# 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 from_client() -proxy_server = FastMCP.from_client( - backend_client, +# Provide the backend in any form accepted by Client +proxy_server = FastMCP.as_proxy( + "backend_server.py", # Could also be a FastMCP instance or a remote URL name="MyProxyServer" # Optional settings for the proxy ) -# That's it! You now have a proxy FastMCP server that can be used -# with any transport (SSE, stdio, etc.) just like any other FastMCP server +# Or create the Client yourself for custom configuration +backend_client = Client("backend_server.py") +proxy_from_client = FastMCP.as_proxy(backend_client) ``` -**How `from_client` Works:** +**How `as_proxy` 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. @@ -72,13 +71,10 @@ Currently, proxying focuses primarily on exposing the major MCP objects (tools, A common use case is to bridge transports. For example, making a remote SSE server available locally via Stdio: ```python -from fastmcp import FastMCP, Client +from fastmcp import FastMCP -# Client targeting a remote SSE server -client = Client("http://example.com/mcp/sse") - -# Create a proxy server - it's just a regular FastMCP server -proxy = FastMCP.from_client(client, name="SSE to Stdio Proxy") +# Target a remote SSE server directly by URL +proxy = FastMCP.as_proxy("http://example.com/mcp/sse", name="SSE to Stdio Proxy") # The proxy can now be used with any transport # No special handling needed - it works like any FastMCP server @@ -89,7 +85,7 @@ proxy = FastMCP.from_client(client, name="SSE to Stdio Proxy") You can also proxy an in-memory `FastMCP` instance, which is useful for adjusting the configuration or behavior of a server you don't completely control. ```python -from fastmcp import FastMCP, Client +from fastmcp import FastMCP # Original server original_server = FastMCP(name="Original") @@ -98,12 +94,9 @@ original_server = FastMCP(name="Original") def tool_a() -> str: return "A" -# To proxy an in-memory server, first create a Client to it. -client_to_original = Client(original_server) - -# Create a proxy of the original server using the client. -proxy = FastMCP.from_client( - client_to_original, +# Create a proxy of the original server directly +proxy = FastMCP.as_proxy( + original_server, name="Proxy Server" ) @@ -113,6 +106,6 @@ proxy = FastMCP.from_client( ## `FastMCPProxy` Class -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. +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. 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/examples/in_memory_proxy_example.py b/examples/in_memory_proxy_example.py index 9620fa113..45e7a10b2 100644 --- a/examples/in_memory_proxy_example.py +++ b/examples/in_memory_proxy_example.py @@ -3,9 +3,8 @@ This example demonstrates how to set up and use an in-memory FastMCP proxy. It illustrates the pattern: 1. Create an original FastMCP server with some tools. -2. Create a Client that connects to this original server (in-memory). -3. Create a proxy FastMCP server using FastMCP.from_client(), passing it the client from step 2. -4. Use another Client to connect to the proxy server (in-memory) and interact with the original server's tools through the proxy. +2. Create a proxy FastMCP server using ``FastMCP.as_proxy(original_server)``. +3. Use another Client to connect to the proxy server (in-memory) and interact with the original server's tools through the proxy. """ import asyncio @@ -36,24 +35,18 @@ async def main(): original_server.add_tool(EchoService().echo) print(f" -> Original Server '{original_server.name}' created.") - # 2. Client for Proxy - print("\nStep 2: Creating a Client to connect to the Original Server...") - print(" (This client will be used internally by the proxy server)") - client_to_original = Client(original_server) - print(f" -> Client for proxy created, targeting '{original_server.name}'.") - - # 3. Proxy Server Creation - print("\nStep 3: Creating the Proxy Server (InMemoryProxy)...") + # 2. Proxy Server Creation + print("\nStep 2: Creating the Proxy Server (InMemoryProxy)...") print( - f" (Using FastMCP.from_client, passing it the client from Step 2 that targets '{original_server.name}')" + f" (Using FastMCP.as_proxy to wrap '{original_server.name}' directly)" ) - proxy_server = FastMCP.from_client(client_to_original, name="InMemoryProxy") + proxy_server = FastMCP.as_proxy(original_server, name="InMemoryProxy") print( f" -> Proxy Server '{proxy_server.name}' created, proxying '{original_server.name}'." ) - # 4. Interacting via Proxy - print("\nStep 4: Using a new Client to connect to the Proxy Server and interact...") + # 3. Interacting via Proxy + print("\nStep 3: Using a new Client to connect to the Proxy Server and interact...") async with Client(proxy_server) as final_client: print(f" -> Successfully connected to proxy '{proxy_server.name}'.") diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index c6aaeedba..ada608760 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -11,6 +11,7 @@ from contextlib import ( asynccontextmanager, ) from functools import partial +from pathlib import Path from typing import TYPE_CHECKING, Any, Generic, Literal import anyio @@ -60,6 +61,7 @@ from fastmcp.utilities.logging import get_logger if TYPE_CHECKING: from fastmcp.client import Client + from fastmcp.client.transports import ClientTransport from fastmcp.server.openapi import FastMCPOpenAPI from fastmcp.server.proxy import FastMCPProxy logger = get_logger(__name__) @@ -1104,14 +1106,47 @@ class FastMCP(Generic[LifespanResultT]): openapi_spec=app.openapi(), client=client, name=name, **settings ) + @classmethod + def as_proxy( + cls, + backend: Client + | ClientTransport + | FastMCP[Any] + | AnyUrl + | Path + | dict[str, Any] + | str, + **settings: Any, + ) -> FastMCPProxy: + """Create a FastMCP proxy server for the given backend. + + The ``backend`` argument can be either an existing :class:`~fastmcp.client.Client` + instance or any value accepted as the ``transport`` argument of + :class:`~fastmcp.client.Client`. This mirrors the convenience of the + ``Client`` constructor. + """ + from fastmcp.server.proxy import FastMCPProxy + + if isinstance(backend, Client): + client = backend + else: + client = Client(backend) + + return FastMCPProxy(client=client, **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 + # Deprecated since 2.4.0 + warnings.warn( + "FastMCP.from_client() is deprecated; use FastMCP.as_proxy() instead.", + DeprecationWarning, + stacklevel=2, + ) - return FastMCPProxy(client=client, **settings) + return cls.as_proxy(client, **settings) def _validate_resource_prefix(prefix: str) -> None: diff --git a/tests/server/test_import_server.py b/tests/server/test_import_server.py index 8b7cade87..ef03ceffc 100644 --- a/tests/server/test_import_server.py +++ b/tests/server/test_import_server.py @@ -299,7 +299,7 @@ async def test_import_with_proxy_tools(): def get_data(query: str) -> str: return f"Data for query: {query}" - proxy_app = FastMCP.from_client(Client(api_app)) + proxy_app = FastMCP.as_proxy(Client(api_app)) await main_app.import_server("api", proxy_app) result = await main_app._mcp_call_tool("api_get_data", {"query": "test"}) @@ -323,7 +323,7 @@ async def test_import_with_proxy_prompts(): """Example greeting prompt.""" return f"Hello, {name} from API!" - proxy_app = FastMCP.from_client(Client(api_app)) + proxy_app = FastMCP.as_proxy(Client(api_app)) await main_app.import_server("api", proxy_app) result = await main_app._mcp_get_prompt("api_greeting", {"name": "World"}) @@ -351,7 +351,7 @@ async def test_import_with_proxy_resources(): "base_url": "https://api.example.com", } - proxy_app = FastMCP.from_client(Client(api_app)) + proxy_app = FastMCP.as_proxy(Client(api_app)) await main_app.import_server("api", proxy_app) # Access the resource through the main app with the prefixed key @@ -379,7 +379,7 @@ async def test_import_with_proxy_resource_templates(): def create_user(name: str, email: str): return {"name": name, "email": email} - proxy_app = FastMCP.from_client(Client(api_app)) + proxy_app = FastMCP.as_proxy(Client(api_app)) await main_app.import_server("api", proxy_app) # Instantiate the template through the main app with the prefixed key diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py index 2d8d37c07..a9eeb5a8c 100644 --- a/tests/server/test_mount.py +++ b/tests/server/test_mount.py @@ -373,7 +373,7 @@ class TestProxyServer: return f"Data for {query}" # Create proxy server - proxy_server = FastMCP.from_client( + proxy_server = FastMCP.as_proxy( Client(transport=FastMCPTransport(original_server)) ) @@ -396,7 +396,7 @@ class TestProxyServer: original_server = FastMCP("OriginalServer") # Create proxy server - proxy_server = FastMCP.from_client( + proxy_server = FastMCP.as_proxy( Client(transport=FastMCPTransport(original_server)) ) @@ -428,7 +428,7 @@ class TestProxyServer: return {"api_key": "12345"} # Create proxy server - proxy_server = FastMCP.from_client( + proxy_server = FastMCP.as_proxy( Client(transport=FastMCPTransport(original_server)) ) @@ -452,7 +452,7 @@ class TestProxyServer: return f"Welcome, {name}!" # Create proxy server - proxy_server = FastMCP.from_client( + proxy_server = FastMCP.as_proxy( Client(transport=FastMCPTransport(original_server)) ) @@ -510,7 +510,7 @@ class TestAsProxyKwarg: async def test_as_proxy_ignored_for_proxy_mounts_default(self): mcp = FastMCP("Main") sub = FastMCP("Sub") - sub_proxy = FastMCP.from_client(Client(transport=FastMCPTransport(sub))) + sub_proxy = FastMCP.as_proxy(Client(transport=FastMCPTransport(sub))) mcp.mount("sub", sub_proxy) @@ -519,7 +519,7 @@ class TestAsProxyKwarg: async def test_as_proxy_ignored_for_proxy_mounts_false(self): mcp = FastMCP("Main") sub = FastMCP("Sub") - sub_proxy = FastMCP.from_client(Client(transport=FastMCPTransport(sub))) + sub_proxy = FastMCP.as_proxy(Client(transport=FastMCPTransport(sub))) mcp.mount("sub", sub_proxy, as_proxy=False) @@ -528,7 +528,7 @@ class TestAsProxyKwarg: async def test_as_proxy_ignored_for_proxy_mounts_true(self): mcp = FastMCP("Main") sub = FastMCP("Sub") - sub_proxy = FastMCP.from_client(Client(transport=FastMCPTransport(sub))) + sub_proxy = FastMCP.as_proxy(Client(transport=FastMCPTransport(sub))) mcp.mount("sub", sub_proxy, as_proxy=True) diff --git a/tests/server/test_proxy.py b/tests/server/test_proxy.py index 31a1c74d4..7f018332c 100644 --- a/tests/server/test_proxy.py +++ b/tests/server/test_proxy.py @@ -66,7 +66,7 @@ def fastmcp_server(): @pytest.fixture async def proxy_server(fastmcp_server): """Fixture that creates a FastMCP proxy server.""" - return FastMCP.from_client(Client(transport=FastMCPTransport(fastmcp_server))) + return FastMCP.as_proxy(Client(transport=FastMCPTransport(fastmcp_server))) async def test_create_proxy(fastmcp_server): @@ -81,6 +81,29 @@ async def test_create_proxy(fastmcp_server): assert server.name == "FastMCP" +async def test_as_proxy_with_server(fastmcp_server): + """FastMCP.as_proxy should accept a FastMCP instance.""" + proxy = FastMCP.as_proxy(fastmcp_server) + result = await proxy._mcp_call_tool("greet", {"name": "Test"}) + assert isinstance(result[0], mcp.types.TextContent) + assert result[0].text == "Hello, Test!" + + +async def test_as_proxy_with_transport(fastmcp_server): + """FastMCP.as_proxy should accept a ClientTransport.""" + proxy = FastMCP.as_proxy(FastMCPTransport(fastmcp_server)) + result = await proxy._mcp_call_tool("greet", {"name": "Test"}) + assert isinstance(result[0], mcp.types.TextContent) + assert result[0].text == "Hello, Test!" + + +def test_as_proxy_with_url(): + """FastMCP.as_proxy should accept a URL without connecting.""" + proxy = FastMCP.as_proxy("http://example.com/mcp") + assert isinstance(proxy, FastMCPProxy) + assert repr(proxy.client.transport).startswith(" Date: Sat, 17 May 2025 12:26:59 -0400 Subject: [PATCH 002/114] Fix import --- src/fastmcp/server/server.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index ada608760..e7d730108 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -1125,6 +1125,7 @@ class FastMCP(Generic[LifespanResultT]): :class:`~fastmcp.client.Client`. This mirrors the convenience of the ``Client`` constructor. """ + from fastmcp.client.client import Client from fastmcp.server.proxy import FastMCPProxy if isinstance(backend, Client): From 5aacd0b3ca9eecd88bb7ec6168426f5214036f75 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 17 May 2025 15:04:55 -0400 Subject: [PATCH 003/114] fastmcp run works with remote servers --- docs/deployment/cli.mdx | 17 +++- src/fastmcp/cli/cli.py | 166 ++++++-------------------------- src/fastmcp/cli/run.py | 179 +++++++++++++++++++++++++++++++++++ src/fastmcp/server/http.py | 3 + src/fastmcp/server/server.py | 17 +++- 5 files changed, 236 insertions(+), 146 deletions(-) create mode 100644 src/fastmcp/cli/run.py diff --git a/docs/deployment/cli.mdx b/docs/deployment/cli.mdx index 1397d8aeb..a010722d2 100644 --- a/docs/deployment/cli.mdx +++ b/docs/deployment/cli.mdx @@ -27,7 +27,7 @@ fastmcp --help ### `run` -Run a FastMCP server directly. +Run a FastMCP server directly or proxy a remote server. ```bash fastmcp run server.py @@ -48,12 +48,13 @@ This command runs the server directly in your current Python environment. You ar #### Server Specification -The server can be specified in two ways: +The server can be specified in three ways: 1. `server.py` - imports the module and looks for a FastMCP object named `mcp`, `server`, or `app`. Errors if no such object is found. 2. `server.py:custom_name` - imports and uses the specified server object +3. `http://server-url/path` or `https://server-url/path` - connects to a remote server and creates a proxy -When using `fastmcp run`, it **ignores** the `if __name__ == "__main__"` block entirely. Instead, it finds your server object and calls its `run()` method directly with the transport options you specify. This means you can use `fastmcp run` to override the transport specified in your code. +When using `fastmcp run` with a local file, it **ignores** the `if __name__ == "__main__"` block entirely. Instead, it finds your server object and calls its `run()` method directly with the transport options you specify. This means you can use `fastmcp run` to override the transport specified in your code. For example, if your code contains: @@ -79,11 +80,17 @@ You can run it with Streamable HTTP transport regardless of what's in the `__mai fastmcp run server.py --transport streamable-http --port 8000 ``` -**Example** +**Examples** ```bash -# Run a server with Streamable HTTP transport on a custom port +# Run a local server with Streamable HTTP transport on a custom port fastmcp run server.py --transport streamable-http --port 8000 + +# Connect to a remote server and proxy as a stdio server +fastmcp run https://example.com/mcp-server + +# Connect to a remote server with specified log level +fastmcp run https://example.com/mcp-server --log-level DEBUG ``` ### `dev` diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py index feda85a0e..819144d22 100644 --- a/src/fastmcp/cli/cli.py +++ b/src/fastmcp/cli/cli.py @@ -17,6 +17,7 @@ from typer import Context, Exit import fastmcp from fastmcp.cli import claude +from fastmcp.cli import run as run_module from fastmcp.utilities.logging import get_logger logger = get_logger("cli") @@ -58,7 +59,7 @@ def _parse_env_var(env_var: str) -> tuple[str, str]: def _build_uv_command( - file_spec: str, + server_spec: str, with_editable: Path | None = None, with_packages: list[str] | None = None, ) -> list[str]: @@ -76,106 +77,10 @@ def _build_uv_command( cmd.extend(["--with", pkg]) # Add mcp run command - cmd.extend(["fastmcp", "run", file_spec]) + cmd.extend(["fastmcp", "run", server_spec]) return cmd -def _parse_file_path(file_spec: str) -> tuple[Path, str | None]: - """Parse a file path that may include a server object specification. - - Args: - file_spec: Path to file, optionally with :object suffix - - Returns: - Tuple of (file_path, server_object) - """ - # First check if we have a Windows path (e.g., C:\...) - has_windows_drive = len(file_spec) > 1 and file_spec[1] == ":" - - # Split on the last colon, but only if it's not part of the Windows drive letter - # and there's actually another colon in the string after the drive letter - if ":" in (file_spec[2:] if has_windows_drive else file_spec): - file_str, server_object = file_spec.rsplit(":", 1) - else: - file_str, server_object = file_spec, None - - # Resolve the file path - file_path = Path(file_str).expanduser().resolve() - if not file_path.exists(): - logger.error(f"File not found: {file_path}") - sys.exit(1) - if not file_path.is_file(): - logger.error(f"Not a file: {file_path}") - sys.exit(1) - - return file_path, server_object - - -def _import_server(file: Path, server_object: str | None = None): - """Import a MCP server from a file. - - Args: - file: Path to the file - server_object: Optional object name in format "module:object" or just "object" - - Returns: - The server object - """ - # Add parent directory to Python path so imports can be resolved - file_dir = str(file.parent) - if file_dir not in sys.path: - sys.path.insert(0, file_dir) - - # Import the module - spec = importlib.util.spec_from_file_location("server_module", file) - if not spec or not spec.loader: - logger.error("Could not load module", extra={"file": str(file)}) - sys.exit(1) - - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - - # If no object specified, try common server names - if not server_object: - # Look for the most common server object names - for name in ["mcp", "server", "app"]: - if hasattr(module, name): - return getattr(module, name) - - logger.error( - f"No server object found in {file}. Please either:\n" - "1. Use a standard variable name (mcp, server, or app)\n" - "2. Specify the object name with file:object syntax", - extra={"file": str(file)}, - ) - sys.exit(1) - - # Handle module:object syntax - if ":" in server_object: - module_name, object_name = server_object.split(":", 1) - try: - server_module = importlib.import_module(module_name) - server = getattr(server_module, object_name, None) - except ImportError: - logger.error( - f"Could not import module '{module_name}'", - extra={"file": str(file)}, - ) - sys.exit(1) - else: - # Just object name - server = getattr(module, server_object, None) - - if server is None: - logger.error( - f"Server object '{server_object}' not found", - extra={"file": str(file)}, - ) - sys.exit(1) - - return server - - @app.command() def version(ctx: Context): if ctx.resilient_parsing: @@ -201,7 +106,7 @@ def version(ctx: Context): @app.command() def dev( - file_spec: str = typer.Argument( + server_spec: str = typer.Argument( ..., help="Python file to run, optionally with :object suffix", ), @@ -246,7 +151,7 @@ def dev( ] = None, ) -> None: """Run a MCP server with the MCP Inspector.""" - file, server_object = _parse_file_path(file_spec) + file, server_object = run_module.parse_file_path(server_spec) logger.debug( "Starting dev server", @@ -262,7 +167,7 @@ def dev( try: # Import server to get dependencies - server = _import_server(file, server_object) + server = run_module.import_server(file, server_object) if hasattr(server, "dependencies") and server.dependencies is not None: with_packages = list(set(with_packages + server.dependencies)) @@ -285,7 +190,7 @@ def dev( if inspector_version: inspector_cmd += f"@{inspector_version}" - uv_cmd = _build_uv_command(file_spec, with_editable, with_packages) + uv_cmd = _build_uv_command(server_spec, with_editable, with_packages) # Run the MCP Inspector command with shell=True on Windows shell = sys.platform == "win32" @@ -318,9 +223,9 @@ def dev( @app.command() def run( - file_spec: str = typer.Argument( + server_spec: str = typer.Argument( ..., - help="Python file to run, optionally with :object suffix", + help="Python file, object specification (file:obj), or URL", ), transport: Annotated[ str | None, @@ -354,22 +259,20 @@ def run( ), ] = None, ) -> None: - """Run a MCP server. + """Run a MCP server or connect to a remote one. - The server can be specified in two ways: - 1. Module approach: server.py - runs the module directly, expecting a server.run() call.\n - 2. Import approach: server.py:app - imports and runs the specified server object.\n\n + The server can be specified in three ways: + 1. Module approach: server.py - runs the module directly, looking for an object named mcp/server/app.\n + 2. Import approach: server.py:app - imports and runs the specified server object.\n + 3. URL approach: http://server-url - connects to a remote server and creates a proxy.\n\n Note: This command runs the server directly. You are responsible for ensuring all dependencies are available. """ - file, server_object = _parse_file_path(file_spec) - logger.debug( - "Running server", + "Running server or client", extra={ - "file": str(file), - "server_object": server_object, + "server_spec": server_spec, "transport": transport, "host": host, "port": port, @@ -378,29 +281,18 @@ def run( ) try: - # Import and get server object - server = _import_server(file, server_object) - - logger.info(f'Found server "{server.name}" in {file}') - - # Run the server - kwargs = {} - if transport: - kwargs["transport"] = transport - if host: - kwargs["host"] = host - if port: - kwargs["port"] = port - if log_level: - kwargs["log_level"] = log_level - - server.run(**kwargs) - + run_module.run_command( + server_spec=server_spec, + transport=transport, + host=host, + port=port, + log_level=log_level, + ) except Exception as e: logger.error( - f"Failed to run server: {e}", + f"Failed to run: {e}", extra={ - "file": str(file), + "server_spec": server_spec, "error": str(e), }, ) @@ -409,7 +301,7 @@ def run( @app.command() def install( - file_spec: str = typer.Argument( + server_spec: str = typer.Argument( ..., help="Python file to run, optionally with :object suffix", ), @@ -466,7 +358,7 @@ def install( Environment variables are preserved once added and only updated if new values are explicitly provided. """ - file, server_object = _parse_file_path(file_spec) + file, server_object = run_module.parse_file_path(server_spec) logger.debug( "Installing server", @@ -489,7 +381,7 @@ def install( server = None if not name: try: - server = _import_server(file, server_object) + server = run_module.import_server(file, server_object) name = server.name except (ImportError, ModuleNotFoundError) as e: logger.debug( @@ -526,7 +418,7 @@ def install( env_dict[key] = value if claude.update_claude_config( - file_spec, + server_spec, name, with_editable=with_editable, with_packages=with_packages, diff --git a/src/fastmcp/cli/run.py b/src/fastmcp/cli/run.py new file mode 100644 index 000000000..fc3bf1780 --- /dev/null +++ b/src/fastmcp/cli/run.py @@ -0,0 +1,179 @@ +"""FastMCP run command implementation.""" + +import importlib.util +import re +import sys +from pathlib import Path +from typing import Any, Literal + +from fastmcp.utilities.logging import get_logger + +logger = get_logger("cli.run") + +TransportType = Literal["stdio", "streamable-http", "sse"] + + +def is_url(path: str) -> bool: + """Check if a string is a URL.""" + url_pattern = re.compile(r"^https?://") + return bool(url_pattern.match(path)) + + +def parse_file_path(server_spec: str) -> tuple[Path, str | None]: + """Parse a file path that may include a server object specification. + + Args: + server_spec: Path to file, optionally with :object suffix + + Returns: + Tuple of (file_path, server_object) + """ + # First check if we have a Windows path (e.g., C:\...) + has_windows_drive = len(server_spec) > 1 and server_spec[1] == ":" + + # Split on the last colon, but only if it's not part of the Windows drive letter + # and there's actually another colon in the string after the drive letter + if ":" in (server_spec[2:] if has_windows_drive else server_spec): + file_str, server_object = server_spec.rsplit(":", 1) + else: + file_str, server_object = server_spec, None + + # Resolve the file path + file_path = Path(file_str).expanduser().resolve() + if not file_path.exists(): + logger.error(f"File not found: {file_path}") + sys.exit(1) + if not file_path.is_file(): + logger.error(f"Not a file: {file_path}") + sys.exit(1) + + return file_path, server_object + + +def import_server(file: Path, server_object: str | None = None) -> Any: + """Import a MCP server from a file. + + Args: + file: Path to the file + server_object: Optional object name in format "module:object" or just "object" + + Returns: + The server object + """ + # Add parent directory to Python path so imports can be resolved + file_dir = str(file.parent) + if file_dir not in sys.path: + sys.path.insert(0, file_dir) + + # Import the module + spec = importlib.util.spec_from_file_location("server_module", file) + if not spec or not spec.loader: + logger.error("Could not load module", extra={"file": str(file)}) + sys.exit(1) + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + # If no object specified, try common server names + if not server_object: + # Look for the most common server object names + for name in ["mcp", "server", "app"]: + if hasattr(module, name): + return getattr(module, name) + + logger.error( + f"No server object found in {file}. Please either:\n" + "1. Use a standard variable name (mcp, server, or app)\n" + "2. Specify the object name with file:object syntax", + extra={"file": str(file)}, + ) + sys.exit(1) + + # Handle module:object syntax + if ":" in server_object: + module_name, object_name = server_object.split(":", 1) + try: + server_module = importlib.import_module(module_name) + server = getattr(server_module, object_name, None) + except ImportError: + logger.error( + f"Could not import module '{module_name}'", + extra={"file": str(file)}, + ) + sys.exit(1) + else: + # Just object name + server = getattr(module, server_object, None) + + if server is None: + logger.error( + f"Server object '{server_object}' not found", + extra={"file": str(file)}, + ) + sys.exit(1) + + return server + + +def create_client_server(url: str) -> Any: + """Create a FastMCP server from a client URL. + + Args: + url: The URL to connect to + + Returns: + A FastMCP server instance + """ + try: + import fastmcp + + client = fastmcp.Client(url) + server = fastmcp.FastMCP.from_client(client) + return server + except Exception as e: + logger.error(f"Failed to create client for URL {url}: {e}") + sys.exit(1) + + +def run_command( + server_spec: str, + transport: str | None = None, + host: str | None = None, + port: int | None = None, + log_level: str | None = None, +) -> None: + """Run a MCP server or connect to a remote one. + + Args: + server_spec: Python file, object specification (file:obj), or URL + transport: Transport protocol to use + host: Host to bind to when using http transport + port: Port to bind to when using http transport + log_level: Log level + """ + if is_url(server_spec): + # Handle URL case + server = create_client_server(server_spec) + logger.debug(f"Created client proxy server for {server_spec}") + else: + # Handle file case + file, server_object = parse_file_path(server_spec) + server = import_server(file, server_object) + logger.debug(f'Found server "{server.name}" in {file}') + + # Run the server + kwargs = {} + if transport: + kwargs["transport"] = transport + if host: + kwargs["host"] = host + if port: + kwargs["port"] = port + if log_level: + kwargs["log_level"] = log_level + + try: + server.run(**kwargs) + except Exception as e: + logger.error(f"Failed to run server: {e}") + sys.exit(1) diff --git a/src/fastmcp/server/http.py b/src/fastmcp/server/http.py index d718d610a..8655b2d64 100644 --- a/src/fastmcp/server/http.py +++ b/src/fastmcp/server/http.py @@ -254,6 +254,7 @@ def create_sse_app( ) # Store the FastMCP server instance on the Starlette app state app.state.fastmcp_server = server + app.state.path = sse_path return app @@ -357,4 +358,6 @@ def create_streamable_http_app( # Store the FastMCP server instance on the Starlette app state app.state.fastmcp_server = server + app.state.path = streamable_http_path + return app diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index c6aaeedba..869368419 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -213,7 +213,7 @@ class FastMCP(Generic[LifespanResultT]): Args: transport: Transport protocol to use ("stdio", "sse", or "streamable-http") """ - logger.info(f'Starting server "{self.name}"...') + logger.debug(f'Starting server "{self.name}"...') anyio.run(partial(self.run_async, transport, **transport_kwargs)) @@ -730,6 +730,7 @@ class FastMCP(Generic[LifespanResultT]): async def run_stdio_async(self) -> None: """Run the server using stdio transport.""" async with stdio_server() as (read_stream, write_stream): + logger.info(f"Starting MCP server {self.name!r} with transport 'stdio'") await self._mcp_server.run( read_stream, write_stream, @@ -763,16 +764,24 @@ class FastMCP(Generic[LifespanResultT]): # lifespan is required for streamable http uvicorn_config["lifespan"] = "on" + host = host or self.settings.host + port = port or self.settings.port + log_level = log_level or self.settings.log_level.lower() + app = self.http_app(path=path, transport=transport, middleware=middleware) config = uvicorn.Config( app, - host=host or self.settings.host, - port=port or self.settings.port, - log_level=log_level or self.settings.log_level.lower(), + host=host, + port=port, + log_level=log_level, **uvicorn_config, ) server = uvicorn.Server(config) + path = app.state.path.lstrip("/") # type: ignore + logger.info( + f"Starting MCP server {self.name!r} with transport {transport!r} on http://{host}:{port}/{path}" + ) await server.serve() async def run_sse_async( From de14845868e0af7f2c99dbcc2f61a1994480685b Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 17 May 2025 15:07:23 -0400 Subject: [PATCH 004/114] Update cli.mdx --- docs/deployment/cli.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/deployment/cli.mdx b/docs/deployment/cli.mdx index a010722d2..7bfd13566 100644 --- a/docs/deployment/cli.mdx +++ b/docs/deployment/cli.mdx @@ -47,6 +47,7 @@ This command runs the server directly in your current Python environment. You ar | Log Level | `--log-level`, `-l` | Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) | #### Server Specification + The server can be specified in three ways: 1. `server.py` - imports the module and looks for a FastMCP object named `mcp`, `server`, or `app`. Errors if no such object is found. From 24ed957f3a6b0e499d9e33919b18dd0ed6a96034 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 17 May 2025 18:02:23 -0400 Subject: [PATCH 005/114] Update tests --- src/fastmcp/server/server.py | 1 - tests/cli/test_cli.py | 113 +++------------ tests/cli/test_run.py | 268 +++++++++++++++++++++++++++++++++-- 3 files changed, 277 insertions(+), 105 deletions(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 869368419..89df5a2cb 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -213,7 +213,6 @@ class FastMCP(Generic[LifespanResultT]): Args: transport: Transport protocol to use ("stdio", "sse", or "streamable-http") """ - logger.debug(f'Starting server "{self.name}"...') anyio.run(partial(self.run_async, transport, **transport_kwargs)) diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 30510506f..2620c6c22 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -173,74 +173,6 @@ class TestHelperFunctions: "file.py:server", ] - def test_parse_file_path_simple(self): - """Test parsing simple file path.""" - with ( - patch("pathlib.Path.exists") as mock_exists, - patch("pathlib.Path.is_file") as mock_is_file, - patch("pathlib.Path.expanduser") as mock_expanduser, - patch("pathlib.Path.resolve") as mock_resolve, - ): - mock_exists.return_value = True - mock_is_file.return_value = True - mock_expanduser.return_value = Path("file.py") - mock_resolve.return_value = Path("file.py") - - path, obj = cli._parse_file_path("file.py") - assert path == Path("file.py") - assert obj is None - - def test_parse_file_path_with_object(self): - """Test parsing file path with object.""" - with ( - patch("pathlib.Path.exists") as mock_exists, - patch("pathlib.Path.is_file") as mock_is_file, - patch("pathlib.Path.expanduser") as mock_expanduser, - patch("pathlib.Path.resolve") as mock_resolve, - ): - mock_exists.return_value = True - mock_is_file.return_value = True - mock_expanduser.return_value = Path("file.py") - mock_resolve.return_value = Path("file.py") - - path, obj = cli._parse_file_path("file.py:server") - assert path == Path("file.py") - assert obj == "server" - - def test_parse_file_path_windows(self): - """Test parsing Windows file path.""" - with ( - patch("pathlib.Path.exists") as mock_exists, - patch("pathlib.Path.is_file") as mock_is_file, - patch("pathlib.Path.expanduser") as mock_expanduser, - patch("pathlib.Path.resolve") as mock_resolve, - ): - mock_exists.return_value = True - mock_is_file.return_value = True - mock_expanduser.return_value = Path("C:/path/file.py") - mock_resolve.return_value = Path("C:/path/file.py") - - path, obj = cli._parse_file_path("C:/path/file.py:server") - assert path == Path("C:/path/file.py") - assert obj == "server" - - def test_parse_file_path_not_file(self, mock_exit, mock_logger): - """Test parsing path that is not a file.""" - with ( - patch("pathlib.Path.exists") as mock_exists, - patch("pathlib.Path.is_file") as mock_is_file, - patch("pathlib.Path.expanduser") as mock_expanduser, - patch("pathlib.Path.resolve") as mock_resolve, - ): - mock_exists.return_value = True - mock_is_file.return_value = False - mock_expanduser.return_value = Path("directory") - mock_resolve.return_value = Path("directory") - - cli._parse_file_path("directory") - mock_logger.error.assert_called_once() - mock_exit.assert_called_once_with(1) - class TestVersionCommand: """Tests for the version command.""" @@ -259,8 +191,8 @@ class TestDevCommand: def test_dev_command_success(self, temp_python_file, mock_logger): """Test successful dev command execution.""" with ( - patch("fastmcp.cli.cli._parse_file_path") as mock_parse, - patch("fastmcp.cli.cli._import_server") as mock_import, + patch("fastmcp.cli.run.parse_file_path") as mock_parse, + patch("fastmcp.cli.run.import_server") as mock_import, patch("fastmcp.cli.cli._get_npx_command") as mock_get_npx, patch("fastmcp.cli.cli._build_uv_command") as mock_build_uv, patch("subprocess.run") as mock_run, @@ -285,8 +217,8 @@ class TestDevCommand: def test_dev_command_with_ui_port(self, temp_python_file): """Test dev command with UI port.""" with ( - patch("fastmcp.cli.cli._parse_file_path") as mock_parse, - patch("fastmcp.cli.cli._import_server") as mock_import, + patch("fastmcp.cli.run.parse_file_path") as mock_parse, + patch("fastmcp.cli.run.import_server") as mock_import, patch("fastmcp.cli.cli._get_npx_command") as mock_get_npx, patch("fastmcp.cli.cli._build_uv_command") as mock_build_uv, patch("subprocess.run") as mock_run, @@ -310,8 +242,8 @@ class TestDevCommand: def test_dev_command_with_server_port(self, temp_python_file): """Test dev command with server port.""" with ( - patch("fastmcp.cli.cli._parse_file_path") as mock_parse, - patch("fastmcp.cli.cli._import_server") as mock_import, + patch("fastmcp.cli.run.parse_file_path") as mock_parse, + patch("fastmcp.cli.run.import_server") as mock_import, patch("fastmcp.cli.cli._get_npx_command") as mock_get_npx, patch("fastmcp.cli.cli._build_uv_command") as mock_build_uv, patch("subprocess.run") as mock_run, @@ -335,8 +267,8 @@ class TestDevCommand: def test_dev_command_inspector_version(self, temp_python_file): """Test dev command with specific inspector version.""" with ( - patch("fastmcp.cli.cli._parse_file_path") as mock_parse, - patch("fastmcp.cli.cli._import_server") as mock_import, + patch("fastmcp.cli.run.parse_file_path") as mock_parse, + patch("fastmcp.cli.run.import_server") as mock_import, patch("fastmcp.cli.cli._get_npx_command") as mock_get_npx, patch("fastmcp.cli.cli._build_uv_command") as mock_build_uv, patch("subprocess.run") as mock_run, @@ -360,11 +292,12 @@ class TestDevCommand: class TestRunCommand: """Tests for the run command.""" - def test_run_command_success(self, temp_python_file, mock_logger): + def test_run_command_success(self, temp_python_file): """Test successful run command execution.""" with ( - patch("fastmcp.cli.cli._parse_file_path") as mock_parse, - patch("fastmcp.cli.cli._import_server") as mock_import, + patch("fastmcp.cli.run.parse_file_path") as mock_parse, + patch("fastmcp.cli.run.import_server") as mock_import, + patch("fastmcp.cli.run.logger") as mock_logger, ): mock_parse.return_value = (temp_python_file, None) mock_server = MagicMock() @@ -374,15 +307,15 @@ class TestRunCommand: result = runner.invoke(cli.app, ["run", str(temp_python_file)]) assert result.exit_code == 0 mock_server.run.assert_called_once_with() - mock_logger.info.assert_called_with( + mock_logger.debug.assert_called_with( f'Found server "test_server" in {temp_python_file}' ) def test_run_command_with_transport(self, temp_python_file): """Test run command with transport option.""" with ( - patch("fastmcp.cli.cli._parse_file_path") as mock_parse, - patch("fastmcp.cli.cli._import_server") as mock_import, + patch("fastmcp.cli.run.parse_file_path") as mock_parse, + patch("fastmcp.cli.run.import_server") as mock_import, ): mock_parse.return_value = (temp_python_file, None) mock_server = MagicMock() @@ -398,8 +331,8 @@ class TestRunCommand: def test_run_command_with_host(self, temp_python_file): """Test run command with host option.""" with ( - patch("fastmcp.cli.cli._parse_file_path") as mock_parse, - patch("fastmcp.cli.cli._import_server") as mock_import, + patch("fastmcp.cli.run.parse_file_path") as mock_parse, + patch("fastmcp.cli.run.import_server") as mock_import, ): mock_parse.return_value = (temp_python_file, None) mock_server = MagicMock() @@ -415,8 +348,8 @@ class TestRunCommand: def test_run_command_with_port(self, temp_python_file): """Test run command with port option.""" with ( - patch("fastmcp.cli.cli._parse_file_path") as mock_parse, - patch("fastmcp.cli.cli._import_server") as mock_import, + patch("fastmcp.cli.run.parse_file_path") as mock_parse, + patch("fastmcp.cli.run.import_server") as mock_import, ): mock_parse.return_value = (temp_python_file, None) mock_server = MagicMock() @@ -432,8 +365,8 @@ class TestRunCommand: def test_run_command_with_log_level(self, temp_python_file): """Test run command with log level option.""" with ( - patch("fastmcp.cli.cli._parse_file_path") as mock_parse, - patch("fastmcp.cli.cli._import_server") as mock_import, + patch("fastmcp.cli.run.parse_file_path") as mock_parse, + patch("fastmcp.cli.run.import_server") as mock_import, ): mock_parse.return_value = (temp_python_file, None) mock_server = MagicMock() @@ -449,8 +382,8 @@ class TestRunCommand: def test_run_command_with_multiple_options(self, temp_python_file): """Test run command with multiple options.""" with ( - patch("fastmcp.cli.cli._parse_file_path") as mock_parse, - patch("fastmcp.cli.cli._import_server") as mock_import, + patch("fastmcp.cli.run.parse_file_path") as mock_parse, + patch("fastmcp.cli.run.import_server") as mock_import, ): mock_parse.return_value = (temp_python_file, None) mock_server = MagicMock() diff --git a/tests/cli/test_run.py b/tests/cli/test_run.py index 827d90a9d..ea4bcefbe 100644 --- a/tests/cli/test_run.py +++ b/tests/cli/test_run.py @@ -1,22 +1,262 @@ +"""Tests for the CLI module.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + import pytest +from typer.testing import CliRunner + +import fastmcp.cli.run +from fastmcp.cli import cli + +# Set up test runner +runner = CliRunner() @pytest.fixture -def server_file(tmp_path): - """Create a simple server file for testing""" - server_path = tmp_path / "test_server.py" - server_path.write_text( - """ -from fastmcp import FastMCP +def mock_console(): + """Mock the rich console to test output.""" + with patch("fastmcp.cli.cli.console") as mock_console: + yield mock_console -mcp = FastMCP(name="TestServer") -@mcp.tool() -def hello(name: str) -> str: - return f"Hello, {name}!" +@pytest.fixture +def mock_logger(): + """Mock the logger to test logging.""" + with patch("fastmcp.cli.cli.logger") as mock_logger: + yield mock_logger -if __name__ == "__main__": - mcp.run() + +@pytest.fixture +def mock_exit(): + """Mock sys.exit to prevent tests from exiting.""" + with patch("sys.exit") as mock_exit: + yield mock_exit + + +@pytest.fixture +def temp_python_file(tmp_path): + """Create a temporary Python file with a test server.""" + server_code = """ +from mcp import Server + +class TestServer(Server): + name = "test_server" + dependencies = ["package1", "package2"] + + def run(self, **kwargs): + print("Running server with", kwargs) + +mcp = TestServer() +server = TestServer() +app = TestServer() +custom_server = TestServer() """ - ) - return server_path + file_path = tmp_path / "test_server.py" + file_path.write_text(server_code) + return file_path + + +@pytest.fixture +def temp_env_file(tmp_path): + """Create a temporary .env file.""" + env_content = """ +TEST_VAR1=value1 +TEST_VAR2=value2 +""" + env_path = tmp_path / ".env" + env_path.write_text(env_content) + return env_path + + +class TestHelperFunctions: + def test_parse_file_path_simple(self): + """Test parsing simple file path.""" + with ( + patch("pathlib.Path.exists") as mock_exists, + patch("pathlib.Path.is_file") as mock_is_file, + patch("pathlib.Path.expanduser") as mock_expanduser, + patch("pathlib.Path.resolve") as mock_resolve, + ): + mock_exists.return_value = True + mock_is_file.return_value = True + mock_expanduser.return_value = Path("file.py") + mock_resolve.return_value = Path("file.py") + + path, obj = fastmcp.cli.run.parse_file_path("file.py") + assert path == Path("file.py") + assert obj is None + + def test_parse_file_path_with_object(self): + """Test parsing file path with object.""" + with ( + patch("pathlib.Path.exists") as mock_exists, + patch("pathlib.Path.is_file") as mock_is_file, + patch("pathlib.Path.expanduser") as mock_expanduser, + patch("pathlib.Path.resolve") as mock_resolve, + ): + mock_exists.return_value = True + mock_is_file.return_value = True + mock_expanduser.return_value = Path("file.py") + mock_resolve.return_value = Path("file.py") + + path, obj = fastmcp.cli.run.parse_file_path("file.py:server") + assert path == Path("file.py") + assert obj == "server" + + def test_parse_file_path_windows(self): + """Test parsing Windows file path.""" + with ( + patch("pathlib.Path.exists") as mock_exists, + patch("pathlib.Path.is_file") as mock_is_file, + patch("pathlib.Path.expanduser") as mock_expanduser, + patch("pathlib.Path.resolve") as mock_resolve, + ): + mock_exists.return_value = True + mock_is_file.return_value = True + mock_expanduser.return_value = Path("C:/path/file.py") + mock_resolve.return_value = Path("C:/path/file.py") + + path, obj = fastmcp.cli.run.parse_file_path("C:/path/file.py:server") + assert path == Path("C:/path/file.py") + assert obj == "server" + + def test_parse_file_path_not_file(self, mock_exit): + """Test parsing path that is not a file.""" + with ( + patch("pathlib.Path.exists") as mock_exists, + patch("pathlib.Path.is_file") as mock_is_file, + patch("pathlib.Path.expanduser") as mock_expanduser, + patch("pathlib.Path.resolve") as mock_resolve, + patch("fastmcp.cli.run.logger") as mock_logger, + ): + mock_exists.return_value = True + mock_is_file.return_value = False + mock_expanduser.return_value = Path("directory") + mock_resolve.return_value = Path("directory") + + fastmcp.cli.run.parse_file_path("directory") + mock_logger.error.assert_called_once() + mock_exit.assert_called_once_with(1) + + +class TestRunCommand: + """Tests for the run command.""" + + def test_run_command_success(self, temp_python_file): + """Test successful run command execution.""" + with ( + patch("fastmcp.cli.run.parse_file_path") as mock_parse, + patch("fastmcp.cli.run.import_server") as mock_import, + patch("fastmcp.cli.run.logger") as mock_logger, + ): + mock_parse.return_value = (temp_python_file, None) + mock_server = MagicMock() + mock_server.name = "test_server" + mock_import.return_value = mock_server + + result = runner.invoke(cli.app, ["run", str(temp_python_file)]) + assert result.exit_code == 0 + mock_server.run.assert_called_once_with() + mock_logger.debug.assert_called_with( + f'Found server "test_server" in {temp_python_file}' + ) + + def test_run_command_with_transport(self, temp_python_file): + """Test run command with transport option.""" + with ( + patch("fastmcp.cli.run.parse_file_path") as mock_parse, + patch("fastmcp.cli.run.import_server") as mock_import, + ): + mock_parse.return_value = (temp_python_file, None) + mock_server = MagicMock() + mock_server.name = "test_server" + mock_import.return_value = mock_server + + result = runner.invoke( + cli.app, ["run", str(temp_python_file), "--transport", "sse"] + ) + assert result.exit_code == 0 + mock_server.run.assert_called_once_with(transport="sse") + + def test_run_command_with_host(self, temp_python_file): + """Test run command with host option.""" + with ( + patch("fastmcp.cli.run.parse_file_path") as mock_parse, + patch("fastmcp.cli.run.import_server") as mock_import, + ): + mock_parse.return_value = (temp_python_file, None) + mock_server = MagicMock() + mock_server.name = "test_server" + mock_import.return_value = mock_server + + result = runner.invoke( + cli.app, ["run", str(temp_python_file), "--host", "0.0.0.0"] + ) + assert result.exit_code == 0 + mock_server.run.assert_called_once_with(host="0.0.0.0") + + def test_run_command_with_port(self, temp_python_file): + """Test run command with port option.""" + with ( + patch("fastmcp.cli.run.parse_file_path") as mock_parse, + patch("fastmcp.cli.run.import_server") as mock_import, + ): + mock_parse.return_value = (temp_python_file, None) + mock_server = MagicMock() + mock_server.name = "test_server" + mock_import.return_value = mock_server + + result = runner.invoke( + cli.app, ["run", str(temp_python_file), "--port", "8080"] + ) + assert result.exit_code == 0 + mock_server.run.assert_called_once_with(port=8080) + + def test_run_command_with_log_level(self, temp_python_file): + """Test run command with log level option.""" + with ( + patch("fastmcp.cli.run.parse_file_path") as mock_parse, + patch("fastmcp.cli.run.import_server") as mock_import, + ): + mock_parse.return_value = (temp_python_file, None) + mock_server = MagicMock() + mock_server.name = "test_server" + mock_import.return_value = mock_server + + result = runner.invoke( + cli.app, ["run", str(temp_python_file), "--log-level", "DEBUG"] + ) + assert result.exit_code == 0 + mock_server.run.assert_called_once_with(log_level="DEBUG") + + def test_run_command_with_multiple_options(self, temp_python_file): + """Test run command with multiple options.""" + with ( + patch("fastmcp.cli.run.parse_file_path") as mock_parse, + patch("fastmcp.cli.run.import_server") as mock_import, + ): + mock_parse.return_value = (temp_python_file, None) + mock_server = MagicMock() + mock_server.name = "test_server" + mock_import.return_value = mock_server + + result = runner.invoke( + cli.app, + [ + "run", + str(temp_python_file), + "--transport", + "sse", + "--host", + "0.0.0.0", + "--port", + "8080", + "--log-level", + "DEBUG", + ], + ) + assert result.exit_code == 0 + mock_server.run.assert_called_once_with( + transport="sse", host="0.0.0.0", port=8080, log_level="DEBUG" + ) From 4978faefa589b1fa41fd996d08e184b8e6b20ca0 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 17 May 2025 19:18:37 -0400 Subject: [PATCH 006/114] Update AGENTS with repo overview --- AGENTS.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..5bb614195 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,24 @@ +# AGENTS + +This repository contains the FastMCP source code and documentation. +It is organized as follows: + +- `src/fastmcp/` – the main library implementation. Key modules include: + - `server/` with the `FastMCP` server and supporting classes like `Context`. + - `client/` with the high-level `Client` for connecting to MCP servers. + - subpackages for `resources`, `prompts`, `tools`, and other utilities. +- `tests/` – pytest-based unit tests for the library. +- `docs/` – documentation written for Mintlify and published on gofastmcp.com. +- `examples/` – small example applications demonstrating library usage. + +Before committing any changes, run: + +```bash +uv sync # install dependencies +uv run pre-commit run --all-files +uv run pytest +``` + +`pre-commit` runs Ruff, Prettier, and Pyright. Make sure changes under +`src/` or `tests/` include corresponding tests. Please keep this file +updated if the repository layout or tooling changes. From a8c46b8efb7ef1f2c9a6bdeee42a495d85798d40 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 17 May 2025 20:06:43 -0400 Subject: [PATCH 007/114] Update AGENTS.md --- AGENTS.md | 75 +++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 59 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5bb614195..223c92090 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,24 +1,67 @@ # AGENTS -This repository contains the FastMCP source code and documentation. -It is organized as follows: +> **Audience**: LLM-driven engineering agents -- `src/fastmcp/` – the main library implementation. Key modules include: - - `server/` with the `FastMCP` server and supporting classes like `Context`. - - `client/` with the high-level `Client` for connecting to MCP servers. - - subpackages for `resources`, `prompts`, `tools`, and other utilities. -- `tests/` – pytest-based unit tests for the library. -- `docs/` – documentation written for Mintlify and published on gofastmcp.com. -- `examples/` – small example applications demonstrating library usage. +This file provides guidance for autonomous coding agents working inside the **FastMCP** repository. -Before committing any changes, run: +--- + +## Repository map + +| Path | Purpose | +| ---------------- | ---------------------------------------------------------------------------------------- | +| `src/fastmcp/` | Library source code (Python ≥ 3.10) | +| ` └─server/` | Server implementation, `FastMCP`, auth, networking | +| ` └─client/` | High‑level client SDK + helpers | +| ` └─resources/` | MCP resources and resource templates | +| ` └─prompts/` | Prompt templates | +| ` └─tools/` | Tool implementations | +| `tests/` | Pytest test‑suite | +| `docs/` | Mintlify‑flavoured Markdown, published to [https://gofastmcp.com](https://gofastmcp.com) | +| `examples/` | Minimal runnable demos | + +--- + +## Mandatory dev workflow ```bash -uv sync # install dependencies -uv run pre-commit run --all-files -uv run pytest +uv sync # install dependencies +uv run pre-commit run --all-files # Ruff + Prettier + Pyright +uv run pytest # run full test suite ``` -`pre-commit` runs Ruff, Prettier, and Pyright. Make sure changes under -`src/` or `tests/` include corresponding tests. Please keep this file -updated if the repository layout or tooling changes. +*Tests must pass* and *lint/typing must be clean* before committing. + +### Core MCP objects + +There are four major MCP object types: + +- Tools (`src/tools/`) +- Resources (`src/resources/`) +- Resource Templates (`src/resources/`) +- Prompts (`src/prompts`) + +While these have slightly different semantics and implementations, in general changes that affect interactions with any one (like adding tags, importing, etc.) will need to be adopted, applied, and tested on all others. Be sure to look at not only the object definition but also the related `Manager` (e.g. `ToolManager`, `ResourceManager`, and `PromptManager`). Also note that while resources and resource templates are different objects, they both are handled by the `ResourceManager`. + +--- + +## Code conventions + +* **Language:** Python ≥ 3.10 +* **Style:** Enforced through pre-commit hooks +* **Type-checking:** Fully typed codebase +* **Tests:** Each feature should have corresponding tests + +--- + +## Development guidelines + +1. **Set up** the environment: + ```bash + uv sync && uv run pre-commit run --all-files + ``` +2. **Run tests**: `uv run pytest` until they pass. +3. **Iterate**: if a command fails, read the output, fix the code, retry. +4. Make the smallest set of changes that achieve the desired outcome. +5. Always read code before modifying it blindly. +6. Follow established patterns and maintain consistency. From d944f6591eb8b952cc004e574fd387b489279429 Mon Sep 17 00:00:00 2001 From: "Amerr, Ziad (ext) (DI SW ICS MNA RD QA QST 2)" Date: Mon, 19 May 2025 20:02:59 +0300 Subject: [PATCH 008/114] Skipping permission test error if the user has root privileges --- tests/resources/test_file_resources.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/resources/test_file_resources.py b/tests/resources/test_file_resources.py index cb622229a..4d2bc63cf 100644 --- a/tests/resources/test_file_resources.py +++ b/tests/resources/test_file_resources.py @@ -99,7 +99,8 @@ class TestFileResource: await resource.read() @pytest.mark.skipif( - os.name == "nt", reason="File permissions behave differently on Windows" + os.name == "nt" or os.getuid() == 0, + reason="File permissions behave differently on Windows or when running as root" ) async def test_permission_error(self, temp_file: Path): """Test reading a file without permissions.""" From 1393a2bb0625ea8df5196cc9678cff40a4bb3cad Mon Sep 17 00:00:00 2001 From: "Amerr, Ziad (ext) (DI SW ICS MNA RD QA QST 2)" Date: Mon, 19 May 2025 20:12:07 +0300 Subject: [PATCH 009/114] Reformatting, and adding copilot's suggestion --- tests/resources/test_file_resources.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/resources/test_file_resources.py b/tests/resources/test_file_resources.py index 4d2bc63cf..06f4576d5 100644 --- a/tests/resources/test_file_resources.py +++ b/tests/resources/test_file_resources.py @@ -99,8 +99,8 @@ class TestFileResource: await resource.read() @pytest.mark.skipif( - os.name == "nt" or os.getuid() == 0, - reason="File permissions behave differently on Windows or when running as root" + os.name == "nt" or (hasattr(os, 'getuid') and os.getuid() == 0), + reason="File permissions behave differently on Windows or when running as root", ) async def test_permission_error(self, temp_file: Path): """Test reading a file without permissions.""" From 2bf349b09fb2237ab5d1ecb511a9959879edbc32 Mon Sep 17 00:00:00 2001 From: "Amerr, Ziad (ext) (DI SW ICS MNA RD QA QST 2)" Date: Mon, 19 May 2025 20:13:52 +0300 Subject: [PATCH 010/114] Fixed formatting again --- tests/resources/test_file_resources.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/resources/test_file_resources.py b/tests/resources/test_file_resources.py index 06f4576d5..2e3f7e153 100644 --- a/tests/resources/test_file_resources.py +++ b/tests/resources/test_file_resources.py @@ -99,7 +99,7 @@ class TestFileResource: await resource.read() @pytest.mark.skipif( - os.name == "nt" or (hasattr(os, 'getuid') and os.getuid() == 0), + os.name == "nt" or (hasattr(os, "getuid") and os.getuid() == 0), reason="File permissions behave differently on Windows or when running as root", ) async def test_permission_error(self, temp_file: Path): From 4561d722ad77f070a678cfe4341d5baa6a4f08d5 Mon Sep 17 00:00:00 2001 From: "Amerr, Ziad (ext) (DI SW ICS MNA RD QA QST 2)" Date: Mon, 19 May 2025 20:14:59 +0300 Subject: [PATCH 011/114] Removed trailing whitespace --- tests/resources/test_file_resources.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/resources/test_file_resources.py b/tests/resources/test_file_resources.py index 2e3f7e153..5f355e360 100644 --- a/tests/resources/test_file_resources.py +++ b/tests/resources/test_file_resources.py @@ -99,7 +99,7 @@ class TestFileResource: await resource.read() @pytest.mark.skipif( - os.name == "nt" or (hasattr(os, "getuid") and os.getuid() == 0), + os.name == "nt" or (hasattr(os, "getuid") and os.getuid() == 0), reason="File permissions behave differently on Windows or when running as root", ) async def test_permission_error(self, temp_file: Path): From 66f3c3f2179d036b87ab7cf01ab07362232e3fd7 Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Mon, 19 May 2025 14:50:16 -0500 Subject: [PATCH 012/114] more permission uvicorn config --- src/fastmcp/server/server.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 6a7d9faf9..4fc5197b4 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -771,13 +771,17 @@ class FastMCP(Generic[LifespanResultT]): app = self.http_app(path=path, transport=transport, middleware=middleware) - config = uvicorn.Config( - app, - host=host, - port=port, - log_level=log_level, + config_kwargs: dict[str, Any] = { + "app": app, + "host": host, + "port": port, **uvicorn_config, - ) + } + + if "log_config" not in uvicorn_config: + config_kwargs["log_level"] = log_level + + config = uvicorn.Config(**config_kwargs) server = uvicorn.Server(config) path = app.state.path.lstrip("/") # type: ignore logger.info( From e783775fc5c051752109444c3d56616a51a0a71d Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Mon, 19 May 2025 15:08:46 -0500 Subject: [PATCH 013/114] add tests --- tests/server/test_logging.py | 176 +++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 tests/server/test_logging.py diff --git a/tests/server/test_logging.py b/tests/server/test_logging.py new file mode 100644 index 000000000..ea827c7f0 --- /dev/null +++ b/tests/server/test_logging.py @@ -0,0 +1,176 @@ +import asyncio +import logging +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from fastmcp.server.server import FastMCP + + +class CustomLogFormatterForTest(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + return f"TEST_FORMAT::{record.levelname}::{record.name}::{record.getMessage()}" + + +@pytest.fixture +def mcp_server() -> FastMCP: + return FastMCP(name="TestLogServer") + + +@patch("fastmcp.server.server.uvicorn.Server") +@patch("fastmcp.server.server.uvicorn.Config") +async def test_uvicorn_logging_default_level( + mock_uvicorn_config_constructor: Mock, + mock_uvicorn_server_constructor: Mock, + mcp_server: FastMCP, +): + """Tests that FastMCP passes log_level to uvicorn.Config if no log_config is given.""" + mock_server_instance = AsyncMock() + mock_uvicorn_server_constructor.return_value = mock_server_instance + serve_finished_event = asyncio.Event() + mock_server_instance.serve.side_effect = serve_finished_event.wait + + test_log_level = "warning" + + server_task = asyncio.create_task( + mcp_server.run_http_async(log_level=test_log_level, port=8003) + ) + await asyncio.sleep(0.01) + + mock_uvicorn_config_constructor.assert_called_once() + _, kwargs_config = mock_uvicorn_config_constructor.call_args + + assert kwargs_config.get("log_level") == test_log_level.lower() + assert "log_config" not in kwargs_config + + mock_uvicorn_server_constructor.assert_called_once_with( + mock_uvicorn_config_constructor.return_value + ) + mock_server_instance.serve.assert_awaited_once() + + server_task.cancel() + with pytest.raises(asyncio.CancelledError): + await server_task + + +@patch("fastmcp.server.server.uvicorn.Server") +@patch("fastmcp.server.server.uvicorn.Config") +async def test_uvicorn_logging_with_custom_log_config( + mock_uvicorn_config_constructor: Mock, + mock_uvicorn_server_constructor: Mock, + mcp_server: FastMCP, +): + """Tests that FastMCP passes log_config to uvicorn.Config and not log_level.""" + mock_server_instance = AsyncMock() + mock_uvicorn_server_constructor.return_value = mock_server_instance + serve_finished_event = asyncio.Event() + mock_server_instance.serve.side_effect = serve_finished_event.wait + + sample_log_config = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "test_formatter": { + "()": "tests.server.test_logging.CustomLogFormatterForTest" + } + }, + "handlers": { + "test_handler": { + "formatter": "test_formatter", + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout", + } + }, + "loggers": { + "uvicorn.error": { + "handlers": ["test_handler"], + "level": "INFO", + "propagate": False, + } + }, + } + + server_task = asyncio.create_task( + mcp_server.run_http_async( + uvicorn_config={"log_config": sample_log_config}, port=8004 + ) + ) + await asyncio.sleep(0.01) + + mock_uvicorn_config_constructor.assert_called_once() + _, kwargs_config = mock_uvicorn_config_constructor.call_args + + assert kwargs_config.get("log_config") == sample_log_config + assert "log_level" not in kwargs_config + + mock_uvicorn_server_constructor.assert_called_once_with( + mock_uvicorn_config_constructor.return_value + ) + mock_server_instance.serve.assert_awaited_once() + + server_task.cancel() + with pytest.raises(asyncio.CancelledError): + await server_task + + +@patch("fastmcp.server.server.uvicorn.Server") +@patch("fastmcp.server.server.uvicorn.Config") +async def test_uvicorn_logging_custom_log_config_overrides_log_level_param( + mock_uvicorn_config_constructor: Mock, + mock_uvicorn_server_constructor: Mock, + mcp_server: FastMCP, +): + """Tests log_config precedence if log_level is also passed to run_http_async.""" + mock_server_instance = AsyncMock() + mock_uvicorn_server_constructor.return_value = mock_server_instance + serve_finished_event = asyncio.Event() + mock_server_instance.serve.side_effect = serve_finished_event.wait + + sample_log_config = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "test_formatter": { + "()": "tests.server.test_logging.CustomLogFormatterForTest" + } + }, + "handlers": { + "test_handler": { + "formatter": "test_formatter", + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout", + } + }, + "loggers": { + "uvicorn.error": { + "handlers": ["test_handler"], + "level": "INFO", + "propagate": False, + } + }, + } + explicit_log_level = "debug" + + server_task = asyncio.create_task( + mcp_server.run_http_async( + log_level=explicit_log_level, + uvicorn_config={"log_config": sample_log_config}, + port=8005, + ) + ) + await asyncio.sleep(0.01) + + mock_uvicorn_config_constructor.assert_called_once() + _, kwargs_config = mock_uvicorn_config_constructor.call_args + + assert kwargs_config.get("log_config") == sample_log_config + assert "log_level" not in kwargs_config + + mock_uvicorn_server_constructor.assert_called_once_with( + mock_uvicorn_config_constructor.return_value + ) + mock_server_instance.serve.assert_awaited_once() + + server_task.cancel() + with pytest.raises(asyncio.CancelledError): + await server_task From 5e627c55d8d96929e0725bf546cdff94c2098e0e Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Mon, 19 May 2025 15:16:47 -0500 Subject: [PATCH 014/114] take one suggestion --- src/fastmcp/server/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 4fc5197b4..7abd32809 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -778,7 +778,7 @@ class FastMCP(Generic[LifespanResultT]): **uvicorn_config, } - if "log_config" not in uvicorn_config: + if "log_config" not in uvicorn_config and "log_level" not in uvicorn_config: config_kwargs["log_level"] = log_level config = uvicorn.Config(**config_kwargs) From 8d210ade05ee4ebdd7684ab013b8ddab330abfe1 Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Mon, 19 May 2025 15:30:46 -0500 Subject: [PATCH 015/114] try to fix test --- tests/server/test_proxy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/server/test_proxy.py b/tests/server/test_proxy.py index 7f018332c..b77db9b38 100644 --- a/tests/server/test_proxy.py +++ b/tests/server/test_proxy.py @@ -74,7 +74,7 @@ async def test_create_proxy(fastmcp_server): # Create a client client = Client(transport=FastMCPTransport(fastmcp_server)) - server = FastMCPProxy.from_client(client) + server = FastMCPProxy.as_proxy(client) assert isinstance(server, FastMCPProxy) assert isinstance(server, FastMCP) From 78ccda1158de37f3c5df76402fef1db6f9b10eb7 Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Mon, 19 May 2025 15:51:16 -0500 Subject: [PATCH 016/114] try one more thing --- src/fastmcp/server/server.py | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 7abd32809..0435ebe02 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -760,28 +760,24 @@ class FastMCP(Generic[LifespanResultT]): path: Path for the endpoint (defaults to settings.streamable_http_path or settings.sse_path) uvicorn_config: Additional configuration for the Uvicorn server """ - uvicorn_config = uvicorn_config or {} - uvicorn_config.setdefault("timeout_graceful_shutdown", 0) - # lifespan is required for streamable http - uvicorn_config["lifespan"] = "on" - host = host or self.settings.host port = port or self.settings.port - log_level = log_level or self.settings.log_level.lower() + default_log_level_to_use = log_level or self.settings.log_level.lower() app = self.http_app(path=path, transport=transport, middleware=middleware) + _uvicorn_config_from_user = uvicorn_config or {} + config_kwargs: dict[str, Any] = { - "app": app, - "host": host, - "port": port, - **uvicorn_config, + "timeout_graceful_shutdown": 0, + "lifespan": "on", } + config_kwargs.update(_uvicorn_config_from_user) - if "log_config" not in uvicorn_config and "log_level" not in uvicorn_config: - config_kwargs["log_level"] = log_level + if "log_config" not in config_kwargs and "log_level" not in config_kwargs: + config_kwargs["log_level"] = default_log_level_to_use - config = uvicorn.Config(**config_kwargs) + config = uvicorn.Config(app, host=host, port=port, **config_kwargs) server = uvicorn.Server(config) path = app.state.path.lstrip("/") # type: ignore logger.info( @@ -1044,9 +1040,6 @@ class FastMCP(Generic[LifespanResultT]): - 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 server server: The FastMCP From 526b831bec81ea03942a5e46739e85bc80152b32 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Mon, 19 May 2025 20:49:03 -0400 Subject: [PATCH 017/114] Store the initialize result on the client --- src/fastmcp/client/client.py | 45 ++++++++++++++++-------- src/fastmcp/client/transports.py | 11 +++--- tests/client/test_client.py | 41 ++++++++++++++++++--- tests/server/test_server_interactions.py | 4 +-- 4 files changed, 74 insertions(+), 27 deletions(-) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 320addb8c..0bfdddeaa 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -1,5 +1,5 @@ import datetime -from contextlib import AsyncExitStack +from contextlib import AsyncExitStack, asynccontextmanager from pathlib import Path from typing import Any, cast @@ -80,6 +80,7 @@ class Client: self._session: ClientSession | None = None self._exit_stack: AsyncExitStack | None = None self._nesting_counter: int = 0 + self._initialize_result: mcp.types.InitializeResult | None = None if isinstance(timeout, int | float): timeout = datetime.timedelta(seconds=timeout) @@ -103,10 +104,19 @@ class Client: """Get the current active session. Raises RuntimeError if not connected.""" if self._session is None: raise RuntimeError( - "Client is not connected. Use 'async with client:' context manager first." + "Client is not connected. Use the 'async with client:' context manager first." ) return self._session + @property + def initialize_result(self) -> mcp.types.InitializeResult: + """Get the result of the initialization request.""" + if self._initialize_result is None: + raise RuntimeError( + "Client is not connected. Use the 'async with client:' context manager first." + ) + return self._initialize_result + def set_roots(self, roots: RootsList | RootsHandler) -> None: """Set the roots for the client. This does not automatically call `send_roots_list_changed`.""" self._session_kwargs["list_roots_callback"] = create_roots_callback(roots) @@ -121,27 +131,35 @@ class Client: """Check if the client is currently connected.""" return self._session is not None + @asynccontextmanager + async def _context_manager(self): + with catch(get_catch_handlers()): + async with self.transport.connect_session( + **self._session_kwargs + ) as session: + self._session = session + # Initialize the session + self._initialize_result = await self._session.initialize() + + try: + yield + finally: + self._exit_stack = None + self._session = None + self._initialize_result = None + async def __aenter__(self): if self._nesting_counter == 0: # Create exit stack to manage both context managers stack = AsyncExitStack() await stack.__aenter__() - # Add the exception handling context - stack.enter_context(catch(get_catch_handlers())) + await stack.enter_async_context(self._context_manager()) - # the above catch will only apply once this __aenter__ finishes so - # we need to wrap the session creation in a new context in case it - # raises errors itself - with catch(get_catch_handlers()): - # Create and enter the transport session using the exit stack - session_cm = self.transport.connect_session(**self._session_kwargs) - self._session = await stack.enter_async_context(session_cm) - - # Store the stack for cleanup in __aexit__ self._exit_stack = stack self._nesting_counter += 1 + return self async def __aexit__(self, exc_type, exc_val, exc_tb): @@ -154,7 +172,6 @@ class Client: await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb) finally: self._exit_stack = None - self._session = None # --- MCP Client Methods --- diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 7faeab613..0b0510ce5 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -44,6 +44,7 @@ class ClientTransport(abc.ABC): A Transport is responsible for establishing and managing connections to an MCP server, and providing a ClientSession within an async context. + """ @abc.abstractmethod @@ -52,7 +53,9 @@ class ClientTransport(abc.ABC): self, **session_kwargs: Unpack[SessionKwargs] ) -> AsyncIterator[ClientSession]: """ - Establishes a connection and yields an active, initialized ClientSession. + Establishes a connection and yields an active ClientSession. + + The ClientSession is *not* expected to be initialized in this context manager. The session is guaranteed to be valid only within the scope of the async context manager. Connection setup and teardown are handled @@ -63,7 +66,7 @@ class ClientTransport(abc.ABC): constructor (e.g., callbacks, timeouts). Yields: - An initialized mcp.ClientSession instance. + A mcp.ClientSession instance. """ raise NotImplementedError yield None # type: ignore @@ -92,7 +95,6 @@ class WSTransport(ClientTransport): async with ClientSession( read_stream, write_stream, **session_kwargs ) as session: - await session.initialize() # Initialize after session creation yield session def __repr__(self) -> str: @@ -141,7 +143,6 @@ class SSETransport(ClientTransport): async with ClientSession( read_stream, write_stream, **session_kwargs ) as session: - await session.initialize() yield session def __repr__(self) -> str: @@ -187,7 +188,6 @@ class StreamableHttpTransport(ClientTransport): async with ClientSession( read_stream, write_stream, **session_kwargs ) as session: - await session.initialize() yield session def __repr__(self) -> str: @@ -235,7 +235,6 @@ class StdioTransport(ClientTransport): async with ClientSession( read_stream, write_stream, **session_kwargs ) as session: - await session.initialize() yield session def __repr__(self) -> str: diff --git a/tests/client/test_client.py b/tests/client/test_client.py index d88a7c53d..cd7464033 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -250,18 +250,51 @@ async def test_read_resource_mcp(fastmcp_server): async def test_client_connection(fastmcp_server): - """Test that the client connects and disconnects properly.""" + """Test that connect is idempotent.""" client = Client(transport=FastMCPTransport(fastmcp_server)) - # Before connection + # Connect idempotently + async with client: + assert client.is_connected() + # Make a request to ensure connection is working + await client.ping() assert not client.is_connected() - # During connection + +async def test_initialize_result_connected(fastmcp_server): + """Test that initialize_result returns the correct result when connected.""" + client = Client(transport=FastMCPTransport(fastmcp_server)) + + # Initialize result should not be accessible before connection + with pytest.raises(RuntimeError, match="Client is not connected"): + _ = client.initialize_result + + async with client: + # Once connected, initialize_result should be available + result = client.initialize_result + + # Verify the initialize result has expected properties + assert hasattr(result, "serverInfo") + assert result.serverInfo.name == "TestServer" + assert result.serverInfo.version is not None + + +async def test_initialize_result_disconnected(fastmcp_server): + """Test that initialize_result raises an error when not connected.""" + client = Client(transport=FastMCPTransport(fastmcp_server)) + + # Initialize result should not be accessible before connection + with pytest.raises(RuntimeError, match="Client is not connected"): + _ = client.initialize_result + + # Connect and then disconnect async with client: assert client.is_connected() - # After connection + # After disconnection, initialize_result should raise an error assert not client.is_connected() + with pytest.raises(RuntimeError, match="Client is not connected"): + _ = client.initialize_result async def test_client_nested_context_manager(fastmcp_server): diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py index 1f9ef6f2b..2d8d2504b 100644 --- a/tests/server/test_server_interactions.py +++ b/tests/server/test_server_interactions.py @@ -640,7 +640,6 @@ class TestToolContextInjection: assert len(result) == 1 content = result[0] assert isinstance(content, TextContent) - assert content.text == "1" async def test_async_context(self): """Test that context works in async functions.""" @@ -798,7 +797,6 @@ class TestResourceContext: async with Client(mcp) as client: result = await client.read_resource(AnyUrl("resource://test")) assert isinstance(result[0], TextResourceContents) - assert result[0].text == "1" class TestResourceTemplates: @@ -1096,7 +1094,7 @@ class TestResourceTemplateContext: async with Client(mcp) as client: result = await client.read_resource(AnyUrl("resource://test")) assert isinstance(result[0], TextResourceContents) - assert result[0].text == "Resource template: test 1" + assert result[0].text.startswith("Resource template: test") class TestPrompts: From 8490905b0ca26490f54bbe9bd3b5d1edb2ab7457 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 20 May 2025 08:57:02 -0400 Subject: [PATCH 018/114] Infer sse transport from url --- docs/clients/transports.mdx | 32 +++++++++++++----- src/fastmcp/client/transports.py | 44 ++++++++++++------------ tests/client/test_client.py | 57 +++++++++++++++++++++++++++++++- 3 files changed, 100 insertions(+), 33 deletions(-) diff --git a/docs/clients/transports.mdx b/docs/clients/transports.mdx index 2b2b24ec5..4653c7233 100644 --- a/docs/clients/transports.mdx +++ b/docs/clients/transports.mdx @@ -40,7 +40,7 @@ Streamable HTTP is the recommended transport for web-based deployments, providin #### Overview - **Class:** `fastmcp.client.transports.StreamableHttpTransport` -- **Inferred From:** URLs starting with `http://` or `https://` (default for HTTP URLs since v2.3.0) +- **Inferred From:** URLs starting with `http://` or `https://` (default for HTTP URLs since v2.3.0) that do not contain `/sse/` in the path - **Server Compatibility:** Works with FastMCP servers running in `streamable-http` mode #### Basic Usage @@ -62,6 +62,15 @@ async def main(): asyncio.run(main()) ``` +You can also explicitly instantiate the transport: + +```python +from fastmcp.client.transports import StreamableHttpTransport + +transport = StreamableHttpTransport(url="https://example.com/mcp") +client = Client(transport) +``` + #### Authentication with Headers For servers requiring authentication: @@ -88,23 +97,19 @@ Server-Sent Events (SSE) is a transport that allows servers to push data to clie #### Overview - **Class:** `fastmcp.client.transports.SSETransport` -- **Inferred From:** Not automatically inferred for HTTP URLs since v2.3.0 (must be explicitly specified) +- **Inferred From:** HTTP URLs containing `/sse/` in the path - **Server Compatibility:** Works with FastMCP servers running in `sse` mode #### Basic Usage -Since v2.3.0, you must explicitly create an `SSETransport` for SSE connections: +The simplest way to use SSE is to let the transport be inferred from a URL with `/sse/` in the path: ```python from fastmcp import Client -from fastmcp.client.transports import SSETransport import asyncio -# Create an SSE transport -transport = SSETransport(url="https://example.com/sse") - -# Pass the transport to the client -client = Client(transport) +# The Client automatically uses SSETransport for URLs containing /sse/ in the path +client = Client("https://example.com/sse") async def main(): async with client: @@ -114,6 +119,15 @@ async def main(): asyncio.run(main()) ``` +You can also explicitly instantiate the transport for URLs that do not contain `/sse/` in the path or for more control: + +```python +from fastmcp.client.transports import SSETransport + +transport = SSETransport(url="https://example.com/sse") +client = Client(transport) +``` + #### Authentication with Headers SSE transport also supports custom headers for authentication: diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 7faeab613..debc2629d 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -1,14 +1,13 @@ import abc import contextlib import datetime -import inspect import os import shutil import sys -import warnings from collections.abc import AsyncIterator from pathlib import Path from typing import Any, TypedDict, cast +from urllib.parse import urlparse from mcp import ClientSession, StdioServerParameters from mcp.client.session import ( @@ -26,6 +25,9 @@ from pydantic import AnyUrl from typing_extensions import Unpack from fastmcp.server import FastMCP as FastMCPServer +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) class SessionKwargs(TypedDict, total=False): @@ -486,36 +488,29 @@ def infer_transport( # the transport is a FastMCP server elif isinstance(transport, FastMCPServer): - return FastMCPTransport(mcp=transport) + inferred_transport = FastMCPTransport(mcp=transport) # the transport is a path to a script elif isinstance(transport, Path | str) and Path(transport).exists(): if str(transport).endswith(".py"): - return PythonStdioTransport(script_path=transport) + inferred_transport = PythonStdioTransport(script_path=transport) elif str(transport).endswith(".js"): - return NodeStdioTransport(script_path=transport) + inferred_transport = NodeStdioTransport(script_path=transport) else: raise ValueError(f"Unsupported script type: {transport}") # the transport is an http(s) URL elif isinstance(transport, AnyUrl | str) and str(transport).startswith("http"): - if str(transport).rstrip("/").endswith("/sse"): - warnings.warn( - inspect.cleandoc( - """ - As of FastMCP 2.3.0, HTTP URLs are inferred to use Streamable HTTP. - The provided URL ends in `/sse`, so you may encounter unexpected behavior. - If you intended to use SSE, please use the `SSETransport` class directly. - """ - ), - category=UserWarning, - stacklevel=2, - ) - return StreamableHttpTransport(url=transport) + transport_str = str(transport) + # Parse out just the path portion to check for /sse + parsed_url = urlparse(transport_str) + path = parsed_url.path - # the transport is a websocket URL - elif isinstance(transport, AnyUrl | str) and str(transport).startswith("ws"): - return WSTransport(url=transport) + # Check if path contains /sse/ or ends with /sse + if "/sse/" in path or path.rstrip("/").endswith("/sse"): + inferred_transport = SSETransport(url=transport) + else: + inferred_transport = StreamableHttpTransport(url=transport) ## if the transport is a config dict elif isinstance(transport, dict): @@ -530,7 +525,7 @@ def infer_transport( server_name = list(server.keys())[0] # Stdio transport if "command" in server[server_name] and "args" in server[server_name]: - return StdioTransport( + inferred_transport = StdioTransport( command=server[server_name]["command"], args=server[server_name]["args"], env=server[server_name].get("env", None), @@ -539,7 +534,7 @@ def infer_transport( # HTTP transport elif "url" in server: - return SSETransport( + inferred_transport = SSETransport( url=server["url"], headers=server.get("headers", None), ) @@ -549,3 +544,6 @@ def infer_transport( # the transport is an unknown type else: raise ValueError(f"Could not infer a valid transport from: {transport}") + + logger.debug(f"Inferred transport: {inferred_transport}") + return inferred_transport diff --git a/tests/client/test_client.py b/tests/client/test_client.py index d88a7c53d..21b9d81f4 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -6,7 +6,12 @@ from mcp import McpError from pydantic import AnyUrl from fastmcp.client import Client -from fastmcp.client.transports import FastMCPTransport +from fastmcp.client.transports import ( + FastMCPTransport, + SSETransport, + StreamableHttpTransport, + infer_transport, +) from fastmcp.exceptions import ResourceError, ToolError from fastmcp.prompts.prompt import TextContent from fastmcp.server.server import FastMCP @@ -543,3 +548,53 @@ class TestTimeout: timeout=0.01, ) as client: await client.call_tool("sleep", {"seconds": 0.1}, timeout=2) + + +class TestInferTransport: + """Tests for the infer_transport function.""" + + @pytest.mark.parametrize( + "url", + [ + "http://example.com/api/sse/stream", + "https://localhost:8080/mcp/sse/endpoint", + "http://example.com/api/sse", + "https://localhost:8080/mcp/sse", + "http://example.com/api/sse?param=value", + "https://localhost:8080/mcp/sse/?param=value", + "https://localhost:8000/mcp/sse?x=1&y=2", + ], + ids=[ + "path_with_sse_directory", + "path_with_sse_subdirectory", + "path_ending_with_sse", + "path_ending_with_sse_https", + "path_with_sse_and_query_params", + "path_with_sse_slash_and_query_params", + "path_with_sse_and_ampersand_param", + ], + ) + def test_url_returns_sse_transport(self, url): + """Test that URLs with /sse/ pattern return SSETransport.""" + assert isinstance(infer_transport(url), SSETransport) + + @pytest.mark.parametrize( + "url", + [ + "http://example.com/api", + "https://localhost:8080/mcp", + "http://example.com/asset/image.jpg", + "https://localhost:8080/sservice/endpoint", + "https://example.com/assets/file", + ], + ids=[ + "regular_http_url", + "regular_https_url", + "url_with_unrelated_path", + "url_with_sservice_in_path", + "url_with_assets_in_path", + ], + ) + def test_url_returns_streamable_http_transport(self, url): + """Test that URLs without /sse/ pattern return StreamableHttpTransport.""" + assert isinstance(infer_transport(url), StreamableHttpTransport) From c71138d6b822d78cac072bd94a04bdaa5021567a Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 20 May 2025 09:27:47 -0400 Subject: [PATCH 019/114] Add progress handler to client --- docs/clients/client.mdx | 79 +++++++++++++++++++++++++++++++- src/fastmcp/client/client.py | 30 ++++++++++-- src/fastmcp/client/logging.py | 8 ++++ src/fastmcp/client/progress.py | 38 +++++++++++++++ src/fastmcp/client/transports.py | 2 + tests/client/test_progress.py | 68 +++++++++++++++++++++++++++ 6 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 src/fastmcp/client/progress.py create mode 100644 tests/client/test_progress.py diff --git a/docs/clients/client.mdx b/docs/clients/client.mdx index 5c9607800..3b086ab5c 100644 --- a/docs/clients/client.mdx +++ b/docs/clients/client.mdx @@ -18,6 +18,17 @@ The FastMCP Client architecture separates the protocol logic (`Client`) from the - **`Client`**: Handles sending MCP requests (like `tools/call`, `resources/read`), receiving responses, and managing callbacks. - **`Transport`**: Responsible for establishing and maintaining the connection to the server (e.g., via WebSockets, SSE, Stdio, or in-memory). +```python +from fastmcp import Client, FastMCP +from fastmcp.client import ( + RootsHandler, + RootsList, + LogHandler, + MessageHandler, + SamplingHandler, + ProgressHandler # For handling progress notifications +) +``` ### Transports @@ -114,7 +125,7 @@ The standard client methods return user-friendly representations that may change tools = await client.list_tools() # tools -> list[mcp.types.Tool] ``` -* **`call_tool(name: str, arguments: dict[str, Any] | None = None, timeout: float | None = None)`**: Executes a tool on the server. +* **`call_tool(name: str, arguments: dict[str, Any] | None = None, timeout: float | None = None, progress_handler: ProgressHandler | None = None)`**: Executes a tool on the server. ```python result = await client.call_tool("add", {"a": 5, "b": 3}) # result -> list[mcp.types.TextContent | mcp.types.ImageContent | ...] @@ -122,10 +133,18 @@ The standard client methods return user-friendly representations that may change # With timeout (aborts if execution takes longer than 2 seconds) result = await client.call_tool("long_running_task", {"param": "value"}, timeout=2.0) + + # With progress handler (to track execution progress) + result = await client.call_tool( + "long_running_task", + {"param": "value"}, + progress_handler=my_progress_handler + ) ``` * Arguments are passed as a dictionary. FastMCP servers automatically handle JSON string parsing for complex types if needed. * Returns a list of content objects (usually `TextContent` or `ImageContent`). * The optional `timeout` parameter limits the maximum execution time (in seconds) for this specific call, overriding any client-level timeout. + * The optional `progress_handler` parameter receives progress updates during execution, overriding any client-level progress handler. #### Resource Operations @@ -234,6 +253,64 @@ Timeout behavior varies between transport types: For consistent behavior across all transports, we recommend explicitly setting timeouts at the individual tool call level when needed, rather than relying on client-level timeouts. +#### Progress Tracking + + + +MCP servers can report progress during long-running operations. The client can set a progress handler to receive and process these updates. + +```python +from fastmcp import Client +from fastmcp.client.progress import ProgressHandler + +# A simple progress handler that prints progress updates +async def my_progress_handler( + progress: float, + total: float | None, + message: str | None +) -> None: + """Handle progress updates from the server.""" + if total is not None: + percent = (progress / total) * 100 + print(f"Progress: {percent:.1f}% ({progress}/{total})") + else: + print(f"Progress: {progress}") + + if message: + print(f"Message: {message}") + +# Set the progress handler at client level +client = Client( + my_mcp_server, + progress_handler=my_progress_handler +) +``` + +By default, FastMCP uses a handler that logs progress updates at the debug level. This default handler properly handles cases where `total` or `message` might be None. + +You can override the progress handler for specific tool calls: + +```python +# Client uses the default debug logger for progress +client = Client(my_mcp_server) + +async with client: + # Use default progress handler (debug logging) + result1 = await client.call_tool("long_task", {"param": "value"}) + + # Override with custom progress handler just for this call + result2 = await client.call_tool( + "another_task", + {"param": "value"}, + progress_handler=my_progress_handler + ) +``` + +A typical progress update includes: +- Current progress value (e.g., 2 of 5 steps completed) +- Total expected value (may be None) +- Status message (may be None) + #### LLM Sampling MCP Servers can request LLM completions from clients. The client can provide a `sampling_handler` to handle these requests. The sampling handler receives a list of messages and other parameters from the server, and should return a string completion. diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 320addb8c..803b5a205 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -8,7 +8,8 @@ from exceptiongroup import catch from mcp import ClientSession from pydantic import AnyUrl -from fastmcp.client.logging import LogHandler, MessageHandler +from fastmcp.client.logging import LogHandler, MessageHandler, default_log_handler +from fastmcp.client.progress import ProgressHandler, default_progress_handler from fastmcp.client.roots import ( RootsHandler, RootsList, @@ -28,6 +29,7 @@ __all__ = [ "LogHandler", "MessageHandler", "SamplingHandler", + "ProgressHandler", ] @@ -50,6 +52,7 @@ class Client: sampling_handler: Optional handler for sampling requests log_handler: Optional handler for log messages message_handler: Optional handler for protocol messages + progress_handler: Optional handler for progress notifications timeout: Optional timeout for requests (seconds or timedelta) Examples: @@ -74,6 +77,7 @@ class Client: sampling_handler: SamplingHandler | None = None, log_handler: LogHandler | None = None, message_handler: MessageHandler | None = None, + progress_handler: ProgressHandler | None = None, timeout: datetime.timedelta | float | int | None = None, ): self.transport = infer_transport(transport) @@ -81,6 +85,14 @@ class Client: self._exit_stack: AsyncExitStack | None = None self._nesting_counter: int = 0 + if log_handler is None: + log_handler = default_log_handler + + if progress_handler is None: + progress_handler = default_progress_handler + + self._progress_handler = progress_handler + if isinstance(timeout, int | float): timeout = datetime.timedelta(seconds=timeout) @@ -96,7 +108,9 @@ class Client: self.set_roots(roots) if sampling_handler is not None: - self.set_sampling_callback(sampling_handler) + self._session_kwargs["sampling_callback"] = create_sampling_callback( + sampling_handler + ) @property def session(self) -> ClientSession: @@ -433,6 +447,7 @@ class Client: self, name: str, arguments: dict[str, Any], + progress_handler: ProgressHandler | None = None, timeout: datetime.timedelta | float | int | None = None, ) -> mcp.types.CallToolResult: """Send a tools/call request and return the complete MCP protocol result. @@ -444,6 +459,8 @@ class Client: name (str): The name of the tool to call. arguments (dict[str, Any]): Arguments to pass to the tool. timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None. + progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None. + Returns: mcp.types.CallToolResult: The complete response object from the protocol, containing the tool result and any additional metadata. @@ -455,7 +472,10 @@ class Client: if isinstance(timeout, int | float): timeout = datetime.timedelta(seconds=timeout) result = await self.session.call_tool( - name=name, arguments=arguments, read_timeout_seconds=timeout + name=name, + arguments=arguments, + read_timeout_seconds=timeout, + progress_callback=progress_handler or self._progress_handler, ) return result @@ -464,6 +484,7 @@ class Client: name: str, arguments: dict[str, Any] | None = None, timeout: datetime.timedelta | float | int | None = None, + progress_handler: ProgressHandler | None = None, ) -> list[ mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource ]: @@ -474,6 +495,8 @@ class Client: Args: name (str): The name of the tool to call. arguments (dict[str, Any] | None, optional): Arguments to pass to the tool. Defaults to None. + timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None. + progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None. Returns: list[mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource]: @@ -487,6 +510,7 @@ class Client: name=name, arguments=arguments or {}, timeout=timeout, + progress_handler=progress_handler, ) if result.isError: msg = cast(mcp.types.TextContent, result.content[0]).text diff --git a/src/fastmcp/client/logging.py b/src/fastmcp/client/logging.py index 2047633e3..826eb0d28 100644 --- a/src/fastmcp/client/logging.py +++ b/src/fastmcp/client/logging.py @@ -6,8 +6,16 @@ from mcp.client.session import ( ) from mcp.types import LoggingMessageNotificationParams +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + LogMessage: TypeAlias = LoggingMessageNotificationParams LogHandler: TypeAlias = LoggingFnT MessageHandler: TypeAlias = MessageHandlerFnT __all__ = ["LogMessage", "LogHandler", "MessageHandler"] + + +async def default_log_handler(params: LogMessage) -> None: + logger.debug(f"Log received: {params}") diff --git a/src/fastmcp/client/progress.py b/src/fastmcp/client/progress.py new file mode 100644 index 000000000..2470f18ce --- /dev/null +++ b/src/fastmcp/client/progress.py @@ -0,0 +1,38 @@ +from typing import TypeAlias + +from mcp.shared.session import ProgressFnT + +from fastmcp.utilities.logging import get_logger + +logger = get_logger(__name__) + +ProgressHandler: TypeAlias = ProgressFnT + + +async def default_progress_handler( + progress: float, total: float | None, message: str | None +) -> None: + """Default handler for progress notifications. + + Logs progress updates at debug level, properly handling missing total or message values. + + Args: + progress: Current progress value + total: Optional total expected value + message: Optional status message + """ + if total is not None: + # We have both progress and total + percent = (progress / total) * 100 + progress_str = f"{progress}/{total} ({percent:.1f}%)" + else: + # We only have progress + progress_str = f"{progress}" + + # Include message if available + if message: + log_msg = f"Progress: {progress_str} - {message}" + else: + log_msg = f"Progress: {progress_str}" + + logger.debug(log_msg) diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 7faeab613..e1597bec8 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -22,6 +22,7 @@ from mcp.client.stdio import stdio_client from mcp.client.streamable_http import streamablehttp_client from mcp.client.websocket import websocket_client from mcp.shared.memory import create_connected_server_and_client_session +from mcp.shared.session import ProgressFnT from pydantic import AnyUrl from typing_extensions import Unpack @@ -35,6 +36,7 @@ class SessionKwargs(TypedDict, total=False): list_roots_callback: ListRootsFnT | None logging_callback: LoggingFnT | None message_handler: MessageHandlerFnT | None + progress_callback: ProgressFnT | None read_timeout_seconds: datetime.timedelta | None diff --git a/tests/client/test_progress.py b/tests/client/test_progress.py new file mode 100644 index 000000000..5f61c3487 --- /dev/null +++ b/tests/client/test_progress.py @@ -0,0 +1,68 @@ +import pytest + +from fastmcp import Client, Context, FastMCP + +PROGRESS_MESSAGES = [] + + +@pytest.fixture(autouse=True) +def clear_progress_messages(): + PROGRESS_MESSAGES.clear() + yield + PROGRESS_MESSAGES.clear() + + +@pytest.fixture +def fastmcp_server(): + mcp = FastMCP() + + @mcp.tool() + async def progress_tool(context: Context) -> int: + for i in range(3): + await context.report_progress( + progress=i + 1, + total=3, + message=f"{(i + 1) / 3 * 100:.2f}% complete", + ) + return 100 + + return mcp + + +EXPECTED_PROGRESS_MESSAGES = [ + dict(progress=1, total=3, message="33.33% complete"), + dict(progress=2, total=3, message="66.67% complete"), + dict(progress=3, total=3, message="100.00% complete"), +] + + +async def progress_handler( + progress: float, total: float | None, message: str | None +) -> None: + PROGRESS_MESSAGES.append(dict(progress=progress, total=total, message=message)) + + +async def test_progress_handler(fastmcp_server: FastMCP): + async with Client(fastmcp_server, progress_handler=progress_handler) as client: + await client.call_tool("progress_tool", {}) + + assert PROGRESS_MESSAGES == EXPECTED_PROGRESS_MESSAGES + + +async def test_progress_handler_can_be_supplied_on_tool_call(fastmcp_server: FastMCP): + async with Client(fastmcp_server) as client: + await client.call_tool("progress_tool", {}, progress_handler=progress_handler) + + assert PROGRESS_MESSAGES == EXPECTED_PROGRESS_MESSAGES + + +async def test_progress_handler_supplied_on_tool_call_overrides_default( + fastmcp_server: FastMCP, +): + async def bad_progress_handler(*args, **kwargs): + 1 / 0 + + async with Client(fastmcp_server, progress_handler=bad_progress_handler) as client: + await client.call_tool("progress_tool", {}, progress_handler=progress_handler) + + assert PROGRESS_MESSAGES == EXPECTED_PROGRESS_MESSAGES From dde2dc471236ee1c569cfdd60cee7382aaab43be Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 20 May 2025 09:30:03 -0400 Subject: [PATCH 020/114] Flaky test --- tests/client/test_client.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 21b9d81f4..6352e9ada 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -1,4 +1,5 @@ import asyncio +import sys from typing import cast import pytest @@ -540,6 +541,10 @@ class TestTimeout: with pytest.raises(McpError): await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01) + @pytest.mark.skipif( + sys.platform == "win32", + reason="This test is flaky on Windows. Sometimes the client timeout is respected and sometimes it is not.", + ) async def test_timeout_tool_call_overrides_client_timeout_even_if_lower( self, fastmcp_server: FastMCP ): From a0e69a4cb25ae8a6a2c7db985398446fb7c67d57 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 20 May 2025 09:33:17 -0400 Subject: [PATCH 021/114] Skip timeout tests on windows --- tests/client/test_client.py | 5 +++++ tests/client/test_sse.py | 12 ++++-------- tests/client/test_streamable_http.py | 4 ++++ 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/client/test_client.py b/tests/client/test_client.py index d88a7c53d..52d4448f8 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -1,4 +1,5 @@ import asyncio +import sys from typing import cast import pytest @@ -509,6 +510,10 @@ class TestErrorHandling: assert "This is a resource error (xyz)" in str(excinfo.value) +@pytest.mark.skipif( + sys.platform == "win32", + reason="Timeout tests are flaky on Windows. Timeouts *are* supported but the tests are unreliable.", +) class TestTimeout: async def test_timeout(self, fastmcp_server: FastMCP): async with Client( diff --git a/tests/client/test_sse.py b/tests/client/test_sse.py index 7ecfc7ee6..787657882 100644 --- a/tests/client/test_sse.py +++ b/tests/client/test_sse.py @@ -136,11 +136,11 @@ async def test_nested_sse_server_resolves_correctly(): assert result is True +@pytest.mark.skipif( + sys.platform == "win32", + reason="Timeout tests are flaky on Windows. Timeouts *are* supported but the tests are unreliable.", +) class TestTimeout: - @pytest.mark.skipif( - sys.platform == "win32", - reason="This test is flaky on Windows. Sometimes the client timeout is respected and sometimes it is not.", - ) async def test_timeout(self, sse_server: str): with pytest.raises( McpError, @@ -167,10 +167,6 @@ class TestTimeout: with pytest.raises(McpError, match="Timed out"): await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01) - @pytest.mark.skipif( - sys.platform == "win32", - reason="This test is flaky on Windows. Sometimes the client timeout is respected and sometimes it is not.", - ) async def test_timeout_client_timeout_does_not_override_tool_call_timeout_if_lower( self, sse_server: str ): diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index be860950e..53e765242 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -149,6 +149,10 @@ async def test_nested_streamable_http_server_resolves_correctly(): assert result is True +@pytest.mark.skipif( + sys.platform == "win32", + reason="Timeout tests are flaky on Windows. Timeouts *are* supported but the tests are unreliable.", +) class TestTimeout: async def test_timeout(self, streamable_http_server: str): # note this transport behaves differently than others and raises From 5a89e0cc2b6c19a734db426fde79a17f80d821cc Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 20 May 2025 09:36:00 -0400 Subject: [PATCH 022/114] Fix typing --- src/fastmcp/client/transports.py | 2 -- tests/client/test_progress.py | 6 ++++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index e1597bec8..7faeab613 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -22,7 +22,6 @@ from mcp.client.stdio import stdio_client from mcp.client.streamable_http import streamablehttp_client from mcp.client.websocket import websocket_client from mcp.shared.memory import create_connected_server_and_client_session -from mcp.shared.session import ProgressFnT from pydantic import AnyUrl from typing_extensions import Unpack @@ -36,7 +35,6 @@ class SessionKwargs(TypedDict, total=False): list_roots_callback: ListRootsFnT | None logging_callback: LoggingFnT | None message_handler: MessageHandlerFnT | None - progress_callback: ProgressFnT | None read_timeout_seconds: datetime.timedelta | None diff --git a/tests/client/test_progress.py b/tests/client/test_progress.py index 5f61c3487..f67f5c54c 100644 --- a/tests/client/test_progress.py +++ b/tests/client/test_progress.py @@ -59,8 +59,10 @@ async def test_progress_handler_can_be_supplied_on_tool_call(fastmcp_server: Fas async def test_progress_handler_supplied_on_tool_call_overrides_default( fastmcp_server: FastMCP, ): - async def bad_progress_handler(*args, **kwargs): - 1 / 0 + async def bad_progress_handler( + progress: float, total: float | None, message: str | None + ) -> None: + raise Exception("This should not be called") async with Client(fastmcp_server, progress_handler=bad_progress_handler) as client: await client.call_tool("progress_tool", {}, progress_handler=progress_handler) From 45b3d1e73a8aa5620d2870d0321ec64e35ed8999 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 20 May 2025 09:37:31 -0400 Subject: [PATCH 023/114] Update release numbers for anticipated version --- docs/deployment/cli.mdx | 2 +- src/fastmcp/server/server.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/deployment/cli.mdx b/docs/deployment/cli.mdx index 7bfd13566..832a82a7e 100644 --- a/docs/deployment/cli.mdx +++ b/docs/deployment/cli.mdx @@ -47,7 +47,7 @@ This command runs the server directly in your current Python environment. You ar | Log Level | `--log-level`, `-l` | Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) | #### Server Specification - + The server can be specified in three ways: 1. `server.py` - imports the module and looks for a FastMCP object named `mcp`, `server`, or `app`. Errors if no such object is found. diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 0435ebe02..d8c86eb65 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -1145,7 +1145,7 @@ class FastMCP(Generic[LifespanResultT]): """ Create a FastMCP proxy server from a FastMCP client. """ - # Deprecated since 2.4.0 + # Deprecated since 2.3.5 warnings.warn( "FastMCP.from_client() is deprecated; use FastMCP.as_proxy() instead.", DeprecationWarning, From eadec830836b6e08ee3611745039c57cff4ed288 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 20 May 2025 10:45:27 -0400 Subject: [PATCH 024/114] Add request id back to tests --- tests/server/test_server_interactions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py index 2d8d2504b..2e739c2b8 100644 --- a/tests/server/test_server_interactions.py +++ b/tests/server/test_server_interactions.py @@ -655,8 +655,7 @@ class TestToolContextInjection: assert len(result) == 1 content = result[0] assert isinstance(content, TextContent) - assert "Async request" in content.text - assert "42" in content.text + assert content.text == "Async request 2: 42" async def test_optional_context(self): """Test that context is optional.""" @@ -797,6 +796,7 @@ class TestResourceContext: async with Client(mcp) as client: result = await client.read_resource(AnyUrl("resource://test")) assert isinstance(result[0], TextResourceContents) + assert result[0].text == "2" class TestResourceTemplates: @@ -1094,7 +1094,7 @@ class TestResourceTemplateContext: async with Client(mcp) as client: result = await client.read_resource(AnyUrl("resource://test")) assert isinstance(result[0], TextResourceContents) - assert result[0].text.startswith("Resource template: test") + assert result[0].text.startswith("Resource template: test 2") class TestPrompts: From e16cb61bc13438f0ce52fdac68e4975b92229e86 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 20 May 2025 11:44:23 -0400 Subject: [PATCH 025/114] Improve client documentation --- docs/clients/client.mdx | 163 +++------------------------------- docs/clients/features.mdx | 152 +++++++++++++++++++++++++++++++ docs/docs.json | 1 + src/fastmcp/client/client.py | 9 +- src/fastmcp/client/logging.py | 22 +++-- tests/client/test_logs.py | 4 +- 6 files changed, 188 insertions(+), 163 deletions(-) create mode 100644 docs/clients/features.mdx diff --git a/docs/clients/client.mdx b/docs/clients/client.mdx index 3b086ab5c..02900b373 100644 --- a/docs/clients/client.mdx +++ b/docs/clients/client.mdx @@ -209,11 +209,19 @@ Available raw MCP methods: These methods are especially useful for debugging or when you need to access metadata or fields that aren't exposed by the simplified methods. -### Advanced Features +### Additional Features -MCP allows servers to interact with clients in order to provide additional capabilities. The `Client` constructor accepts additional configuration to handle these server requests. +#### Pinging the server -#### Timeout Control +The client can be used to ping the server to verify connectivity. + +```python +async with client: + await client.ping() + print("Server is reachable") +``` + +#### Timeouts @@ -253,154 +261,7 @@ Timeout behavior varies between transport types: For consistent behavior across all transports, we recommend explicitly setting timeouts at the individual tool call level when needed, rather than relying on client-level timeouts. -#### Progress Tracking - - - -MCP servers can report progress during long-running operations. The client can set a progress handler to receive and process these updates. - -```python -from fastmcp import Client -from fastmcp.client.progress import ProgressHandler - -# A simple progress handler that prints progress updates -async def my_progress_handler( - progress: float, - total: float | None, - message: str | None -) -> None: - """Handle progress updates from the server.""" - if total is not None: - percent = (progress / total) * 100 - print(f"Progress: {percent:.1f}% ({progress}/{total})") - else: - print(f"Progress: {progress}") - - if message: - print(f"Message: {message}") - -# Set the progress handler at client level -client = Client( - my_mcp_server, - progress_handler=my_progress_handler -) -``` - -By default, FastMCP uses a handler that logs progress updates at the debug level. This default handler properly handles cases where `total` or `message` might be None. - -You can override the progress handler for specific tool calls: - -```python -# Client uses the default debug logger for progress -client = Client(my_mcp_server) - -async with client: - # Use default progress handler (debug logging) - result1 = await client.call_tool("long_task", {"param": "value"}) - - # Override with custom progress handler just for this call - result2 = await client.call_tool( - "another_task", - {"param": "value"}, - progress_handler=my_progress_handler - ) -``` - -A typical progress update includes: -- Current progress value (e.g., 2 of 5 steps completed) -- Total expected value (may be None) -- Status message (may be None) - -#### LLM Sampling - -MCP Servers can request LLM completions from clients. The client can provide a `sampling_handler` to handle these requests. The sampling handler receives a list of messages and other parameters from the server, and should return a string completion. - -The following example uses the `marvin` library to generate a completion: - -```python {8-17, 21} -import marvin -from fastmcp import Client -from fastmcp.client.sampling import ( - SamplingMessage, - SamplingParams, - RequestContext, -) - -async def sampling_handler( - messages: list[SamplingMessage], - params: SamplingParams, - context: RequestContext -) -> str: - return await marvin.say_async( - message=[m.content.text for m in messages], - instructions=params.systemPrompt, - ) - -client = Client( - ..., - sampling_handler=sampling_handler, -) -``` - -#### Logging - -MCP servers can emit logs to clients. The client can set a logging callback to receive these logs. - -```python {4-5, 9} -from fastmcp import Client -from fastmcp.client.logging import LogHandler, LogMessage - -async def my_log_handler(params: LogMessage): - print(f"[Server Log - {params.level.upper()}] {params.logger or 'default'}: {params.data}") - -client_with_logging = Client( - ..., - log_handler=my_log_handler, -) -``` - -#### Roots - -Roots are a way for clients to inform servers about the resources they have access to or certain boundaries on their access. The server can use this information to adjust behavior or provide more accurate responses. - -Servers can request roots from clients, and clients can notify servers when their roots change. - -To set the roots when creating a client, users can either provide a list of roots (which can be a list of strings) or an async function that returns a list of roots. - - -```python Static Roots {5} -from fastmcp import Client - -client = Client( - ..., - roots=["/path/to/root1", "/path/to/root2"], -) -``` -```python Dynamic Roots Callback {4-6, 10} -from fastmcp import Client -from fastmcp.client.roots import RequestContext - -async def roots_callback(context: RequestContext) -> list[str]: - print(f"Server requested roots (Request ID: {context.request_id})") - return ["/path/to/root1", "/path/to/root2"] - -client = Client( - ..., - roots=roots_callback, -) -``` - -### Utility Methods - -* **`ping()`**: Sends a ping request to the server to verify connectivity. - ```python - async def check_connection(): - async with client: - await client.ping() - print("Server is reachable") - ``` - -### Error Handling +#### Error Handling When a `call_tool` request results in an error on the server (e.g., the tool function raised an exception), the `client.call_tool()` method will raise a `fastmcp.client.ClientError`. diff --git a/docs/clients/features.mdx b/docs/clients/features.mdx new file mode 100644 index 000000000..cee3c461b --- /dev/null +++ b/docs/clients/features.mdx @@ -0,0 +1,152 @@ +--- +title: Advanced Features +sidebarTitle: Advanced Features +description: Learn about the advanced features of the FastMCP Client. +icon: stars +--- + +import { VersionBadge } from '/snippets/version-badge.mdx' + +In addition to basic server interaction, FastMCP clients can also handle more advanced features and server interaction patterns. The `Client` constructor accepts additional configuration to handle these server requests. + + +To enable many of these features, you must provide an appropriate handler or callback function. For example. In most cases, if you do not provide a handler, FastMCP's default handler will emit a `DEBUG` level log. + + +## Logging and Notifications + + +MCP servers can emit logs to clients. To process these logs, you can provide a `log_handler` to the client. + +The `log_handler` must be an async function that accepts a single argument, which is an instance of `fastmcp.client.logging.LogMessage`. This has attributes like `level`, `logger`, and `data`. + +```python {2, 12} +from fastmcp import Client +from fastmcp.client.logging import LogMessage + +async def log_handler(message: LogMessage): + level = message.level.upper() + logger = message.logger or 'default' + data = message.data + print(f"[Server Log - {level}] {logger}: {data}") + +client_with_logging = Client( + ..., + log_handler=log_handler, +) +``` +## Progress Monitoring + + + +MCP servers can report progress during long-running operations. The client can set a progress handler to receive and process these updates. + +```python {2, 13} +from fastmcp import Client +from fastmcp.client.progress import ProgressHandler + +async def my_progress_handler( + progress: float, + total: float | None, + message: str | None +) -> None: + print(f"Progress: {progress} / {total} ({message})") + +client = Client( + ..., + progress_handler=my_progress_handler +) +``` + +By default, FastMCP uses a handler that logs progress updates at the debug level. This default handler properly handles cases where `total` or `message` might be None. + +You can override the progress handler for specific tool calls: + +```python +# Client uses the default debug logger for progress +client = Client(...) + +async with client: + # Use default progress handler (debug logging) + result1 = await client.call_tool("long_task", {"param": "value"}) + + # Override with custom progress handler just for this call + result2 = await client.call_tool( + "another_task", + {"param": "value"}, + progress_handler=my_progress_handler + ) +``` + +A typical progress update includes: +- Current progress value (e.g., 2 of 5 steps completed) +- Total expected value (may be None) +- Status message (may be None) + +## LLM Sampling + + + +MCP Servers can request LLM completions from clients. The client can provide a `sampling_handler` to handle these requests. The sampling handler receives a list of messages and other parameters from the server, and should return a string completion. + +The following example uses the `marvin` library to generate a completion: + +```python {8-17, 21} +import marvin +from fastmcp import Client +from fastmcp.client.sampling import ( + SamplingMessage, + SamplingParams, + RequestContext, +) + +async def sampling_handler( + messages: list[SamplingMessage], + params: SamplingParams, + context: RequestContext +) -> str: + return await marvin.say_async( + message=[m.content.text for m in messages], + instructions=params.systemPrompt, + ) + +client = Client( + ..., + sampling_handler=sampling_handler, +) +``` + + +## Roots + + + +Roots are a way for clients to inform servers about the resources they have access to or certain boundaries on their access. The server can use this information to adjust behavior or provide more accurate responses. + +Servers can request roots from clients, and clients can notify servers when their roots change. + +To set the roots when creating a client, users can either provide a list of roots (which can be a list of strings) or an async function that returns a list of roots. + + +```python Static Roots {5} +from fastmcp import Client + +client = Client( + ..., + roots=["/path/to/root1", "/path/to/root2"], +) +``` +```python Dynamic Roots Callback {4-6, 10} +from fastmcp import Client +from fastmcp.client.roots import RequestContext + +async def roots_callback(context: RequestContext) -> list[str]: + print(f"Server requested roots (Request ID: {context.request_id})") + return ["/path/to/root1", "/path/to/root2"] + +client = Client( + ..., + roots=roots_callback, +) +``` + \ No newline at end of file diff --git a/docs/docs.json b/docs/docs.json index 8498554b4..27e4a506c 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -67,6 +67,7 @@ "group": "Clients", "pages": [ "clients/client", + "clients/features", "clients/transports" ] }, diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 01cfc287c..dcb4ff013 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -8,7 +8,12 @@ from exceptiongroup import catch from mcp import ClientSession from pydantic import AnyUrl -from fastmcp.client.logging import LogHandler, MessageHandler, default_log_handler +from fastmcp.client.logging import ( + LogHandler, + MessageHandler, + create_log_callback, + default_log_handler, +) from fastmcp.client.progress import ProgressHandler, default_progress_handler from fastmcp.client.roots import ( RootsHandler, @@ -100,7 +105,7 @@ class Client: self._session_kwargs: SessionKwargs = { "sampling_callback": None, "list_roots_callback": None, - "logging_callback": log_handler, + "logging_callback": create_log_callback(log_handler), "message_handler": message_handler, "read_timeout_seconds": timeout, } diff --git a/src/fastmcp/client/logging.py b/src/fastmcp/client/logging.py index 826eb0d28..d309a0674 100644 --- a/src/fastmcp/client/logging.py +++ b/src/fastmcp/client/logging.py @@ -1,9 +1,7 @@ +from collections.abc import Awaitable, Callable from typing import TypeAlias -from mcp.client.session import ( - LoggingFnT, - MessageHandlerFnT, -) +from mcp.client.session import LoggingFnT, MessageHandlerFnT from mcp.types import LoggingMessageNotificationParams from fastmcp.utilities.logging import get_logger @@ -11,11 +9,19 @@ from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) LogMessage: TypeAlias = LoggingMessageNotificationParams -LogHandler: TypeAlias = LoggingFnT +LogHandler: TypeAlias = Callable[[LogMessage], Awaitable[None]] MessageHandler: TypeAlias = MessageHandlerFnT -__all__ = ["LogMessage", "LogHandler", "MessageHandler"] + +async def default_log_handler(message: LogMessage) -> None: + logger.debug(f"Log received: {message}") -async def default_log_handler(params: LogMessage) -> None: - logger.debug(f"Log received: {params}") +def create_log_callback(handler: LogHandler | None = None) -> LoggingFnT: + if handler is None: + handler = default_log_handler + + async def log_callback(params: LoggingMessageNotificationParams) -> None: + await handler(params) + + return log_callback diff --git a/tests/client/test_logs.py b/tests/client/test_logs.py index 99b5f3db5..93f7720a1 100644 --- a/tests/client/test_logs.py +++ b/tests/client/test_logs.py @@ -9,8 +9,8 @@ class LogHandler: def __init__(self): self.logs: list[LogMessage] = [] - async def handle_log(self, params: LogMessage) -> None: - self.logs.append(params) + async def handle_log(self, message: LogMessage) -> None: + self.logs.append(message) @pytest.fixture From a4f6ecd0be8720cad0918825f9d58b9736c1e72a Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 20 May 2025 11:50:57 -0400 Subject: [PATCH 026/114] Ensure openapi path params are handled properly --- src/fastmcp/server/openapi.py | 18 ++ tests/server/test_openapi_array_params.py | 90 +++++++++ tests/server/test_openapi_path_parameters.py | 190 +++++++++++++++++++ 3 files changed, 298 insertions(+) create mode 100644 tests/server/test_openapi_array_params.py create mode 100644 tests/server/test_openapi_path_parameters.py diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py index 19ee1e42b..b64b6c7d1 100644 --- a/src/fastmcp/server/openapi.py +++ b/src/fastmcp/server/openapi.py @@ -171,6 +171,24 @@ class OpenAPITool(Tool): raise ToolError(f"Missing required path parameters: {missing_params}") for param_name, param_value in path_params.items(): + # Handle array path parameters with style 'simple' (comma-separated) + # In OpenAPI, 'simple' is the default style for path parameters + # and explode=False behavior for arrays + param_info = next( + (p for p in self._route.parameters if p.name == param_name), None + ) + + if param_info and isinstance(param_value, list): + # Check if schema indicates an array type + schema = param_info.schema_ + is_array = schema.get("type") == "array" + + if is_array: + # Format array values as comma-separated string + # This follows the OpenAPI 'simple' style (default for path) + # and explode=False behavior for arrays + param_value = ",".join(str(item) for item in param_value) + path = path.replace(f"{{{param_name}}}", str(param_value)) # Prepare query parameters - filter out None and empty strings diff --git a/tests/server/test_openapi_array_params.py b/tests/server/test_openapi_array_params.py new file mode 100644 index 000000000..b87149038 --- /dev/null +++ b/tests/server/test_openapi_array_params.py @@ -0,0 +1,90 @@ +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +from fastmcp.server.openapi import OpenAPITool +from fastmcp.utilities.openapi import HTTPRoute, ParameterInfo + + +@pytest.fixture +def mock_client(): + """Create a mock httpx.AsyncClient.""" + client = AsyncMock(spec=httpx.AsyncClient) + # Set up a mock response + mock_response = MagicMock() + mock_response.json.return_value = {"result": "success"} + mock_response.raise_for_status.return_value = None + client.request.return_value = mock_response + return client + + +@pytest.mark.asyncio +async def test_array_path_parameter_handling(mock_client): + """Test how array path parameters are handled.""" + # Create a simple route with array path parameter + route = HTTPRoute( + path="/select/{days}", + method="PUT", + operation_id="test-operation", + parameters=[ + ParameterInfo( + name="days", + location="path", + required=True, + schema={ + "type": "array", + "items": { + "type": "string", + "enum": [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", + ], + }, + }, + ) + ], + ) + + # Create the tool + tool = OpenAPITool( + client=mock_client, + route=route, + name="test-operation", + description="Test operation", + parameters={}, + ) + + # Test with a single value + await tool._execute_request(days=["monday"]) + + # Check that the path parameter is formatted correctly + # This is where the bug is: it should be '/select/monday' not '/select/[\'monday\']' + mock_client.request.assert_called_with( + method="PUT", + url="/select/monday", # This is the expected format + params={}, + headers={}, + json=None, + timeout=None, + ) + mock_client.request.reset_mock() + + # Test with multiple values + await tool._execute_request(days=["monday", "tuesday"]) + + # Check that the path parameter is formatted correctly + # It should be '/select/monday,tuesday' not '/select/[\'monday\', \'tuesday\']' + mock_client.request.assert_called_with( + method="PUT", + url="/select/monday,tuesday", # This is the expected format + params={}, + headers={}, + json=None, + timeout=None, + ) diff --git a/tests/server/test_openapi_path_parameters.py b/tests/server/test_openapi_path_parameters.py new file mode 100644 index 000000000..2db4fa2bc --- /dev/null +++ b/tests/server/test_openapi_path_parameters.py @@ -0,0 +1,190 @@ +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + +from fastmcp import FastMCP +from fastmcp.server.openapi import OpenAPITool +from fastmcp.utilities.openapi import HTTPRoute, ParameterInfo + + +@pytest.fixture +def array_path_spec(): + """Load a minimal OpenAPI spec with an array path parameter.""" + return { + "openapi": "3.1.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "paths": { + "/select/{days}": { + "put": { + "operationId": "test-operation", + "parameters": [ + { + "name": "days", + "in": "path", + "required": True, + "style": "simple", + "explode": False, + "schema": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", + ], + }, + }, + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"result": {"type": "string"}}, + "required": ["result"], + } + } + }, + } + }, + } + } + }, + } + + +@pytest.fixture +def mock_client(): + """Create a mock httpx.AsyncClient.""" + client = AsyncMock(spec=httpx.AsyncClient) + # Set up a mock response + mock_response = MagicMock() + mock_response.json.return_value = {"result": "success"} + mock_response.raise_for_status.return_value = None + client.request.return_value = mock_response + return client + + +async def test_fastmcp_from_openapi(array_path_spec, mock_client): + """Test creating FastMCP from OpenAPI spec with array path parameter.""" + # Create FastMCP from the spec + mcp = FastMCP.from_openapi(array_path_spec, client=mock_client) + + # Verify the tool was created using the MCP protocol method + tools_result = await mcp.get_tools() + tool_names = [tool.name for tool in tools_result.values()] + assert "test-operation" in tool_names + + +@pytest.mark.asyncio +async def test_array_path_parameter_handling(mock_client): + """Test how array path parameters are handled.""" + # Create a simple route with array path parameter + route = HTTPRoute( + path="/select/{days}", + method="PUT", + operation_id="test-operation", + parameters=[ + ParameterInfo( + name="days", + location="path", + required=True, + schema={ + "type": "array", + "items": { + "type": "string", + "enum": [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", + ], + }, + }, + ) + ], + ) + + # Create the tool + tool = OpenAPITool( + client=mock_client, + route=route, + name="test-operation", + description="Test operation", + parameters={}, + ) + + # Test with a single value + await tool._execute_request(days=["monday"]) + + # Check that the path parameter is formatted correctly + # This is where the bug is: it should be '/select/monday' not '/select/[\'monday\']' + mock_client.request.assert_called_with( + method="PUT", + url="/select/monday", # This is the expected format + params={}, + headers={}, + json=None, + timeout=None, + ) + mock_client.request.reset_mock() + + # Test with multiple values + await tool._execute_request(days=["monday", "tuesday"]) + + # Check that the path parameter is formatted correctly + # It should be '/select/monday,tuesday' not '/select/[\'monday\', \'tuesday\']' + mock_client.request.assert_called_with( + method="PUT", + url="/select/monday,tuesday", # This is the expected format + params={}, + headers={}, + json=None, + timeout=None, + ) + + +@pytest.mark.asyncio +async def test_integration_array_path_parameter(array_path_spec, mock_client): + """Integration test for array path parameters.""" + # Create FastMCP from the spec + mcp = FastMCP.from_openapi(array_path_spec, client=mock_client) + + # Call the tool with a single value + await mcp._mcp_call_tool("test-operation", {"days": ["monday"]}) + + # Check the request was made correctly + mock_client.request.assert_called_with( + method="PUT", + url="/select/monday", + params={}, + headers={}, + json=None, + timeout=None, + ) + mock_client.request.reset_mock() + + # Call the tool with multiple values + await mcp._mcp_call_tool("test-operation", {"days": ["monday", "tuesday"]}) + + # Check the request was made correctly + mock_client.request.assert_called_with( + method="PUT", + url="/select/monday,tuesday", + params={}, + headers={}, + json=None, + timeout=None, + ) From 2cce73a133a98a329c4bed5db0ca78735c4f1c52 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 20 May 2025 12:12:47 -0400 Subject: [PATCH 027/114] Unify openapi 3.0 and 3.1 parsing --- src/fastmcp/server/openapi.py | 45 +- src/fastmcp/utilities/openapi.py | 833 ++++++------------- tests/server/test_openapi_path_parameters.py | 70 ++ 3 files changed, 344 insertions(+), 604 deletions(-) diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py index b64b6c7d1..cc05c2692 100644 --- a/src/fastmcp/server/openapi.py +++ b/src/fastmcp/server/openapi.py @@ -173,7 +173,6 @@ class OpenAPITool(Tool): for param_name, param_value in path_params.items(): # Handle array path parameters with style 'simple' (comma-separated) # In OpenAPI, 'simple' is the default style for path parameters - # and explode=False behavior for arrays param_info = next( (p for p in self._route.parameters if p.name == param_name), None ) @@ -186,9 +185,49 @@ class OpenAPITool(Tool): if is_array: # Format array values as comma-separated string # This follows the OpenAPI 'simple' style (default for path) - # and explode=False behavior for arrays - param_value = ",".join(str(item) for item in param_value) + if all( + isinstance(item, str | int | float | bool) + for item in param_value + ): + # Handle simple array types + path = path.replace( + f"{{{param_name}}}", ",".join(str(v) for v in param_value) + ) + else: + # Handle complex array types (containing objects/dicts) + try: + # Try to create a simple representation without Python syntax artifacts + formatted_parts = [] + for item in param_value: + if isinstance(item, dict): + # For objects, serialize key-value pairs + item_parts = [] + for k, v in item.items(): + item_parts.append(f"{k}:{v}") + formatted_parts.append(".".join(item_parts)) + else: + # Fallback for other complex types + formatted_parts.append(str(item)) + # Join parts with commas + formatted_value = ",".join(formatted_parts) + path = path.replace(f"{{{param_name}}}", formatted_value) + except Exception as e: + logger.warning( + f"Failed to format complex array path parameter '{param_name}': {e}" + ) + # Fallback to string representation, but remove Python syntax artifacts + str_value = ( + str(param_value) + .replace("[", "") + .replace("]", "") + .replace("'", "") + .replace('"', "") + ) + path = path.replace(f"{{{param_name}}}", str_value) + continue + + # Default handling for non-array parameters or non-array schemas path = path.replace(f"{{{param_name}}}", str(param_value)) # Prepare query parameters - filter out None and empty strings diff --git a/src/fastmcp/utilities/openapi.py b/src/fastmcp/utilities/openapi.py index 64297e86f..c56d9c558 100644 --- a/src/fastmcp/utilities/openapi.py +++ b/src/fastmcp/utilities/openapi.py @@ -1,10 +1,8 @@ import json import logging -from typing import Any, Literal, cast +from typing import Any, Generic, Literal, TypeVar -# Using the recommended library: openapi-pydantic from openapi_pydantic import ( - MediaType, OpenAPI, Operation, Parameter, @@ -26,7 +24,7 @@ from openapi_pydantic.v3.v3_0 import Response as Response_30 from openapi_pydantic.v3.v3_0 import Schema as Schema_30 from pydantic import BaseModel, Field, ValidationError -from fastmcp.utilities import openapi +from fastmcp.utilities.json_schema import compress_schema logger = logging.getLogger(__name__) @@ -49,8 +47,6 @@ class ParameterInfo(BaseModel): schema_: JsonSchema = Field(..., alias="schema") # Target name in IR description: str | None = None - # No model_config needed here if we populate manually after accessing 'in' - class RequestBodyInfo(BaseModel): """Represents the request body for an HTTP operation in our IR.""" @@ -101,60 +97,17 @@ __all__ = [ "parse_openapi_to_http_routes", ] -# --- Helper Functions --- +# Type variables for generic parser +TOpenAPI = TypeVar("TOpenAPI", OpenAPI, OpenAPI_30) +TSchema = TypeVar("TSchema", Schema, Schema_30) +TReference = TypeVar("TReference", Reference, Reference_30) +TParameter = TypeVar("TParameter", Parameter, Parameter_30) +TRequestBody = TypeVar("TRequestBody", RequestBody, RequestBody_30) +TResponse = TypeVar("TResponse", Response, Response_30) +TOperation = TypeVar("TOperation", Operation, Operation_30) +TPathItem = TypeVar("TPathItem", PathItem, PathItem_30) -def _resolve_ref( - item: Reference | Schema | Parameter | RequestBody | Any, openapi: OpenAPI -) -> Any: - """Resolves a potential Reference object to its target definition (no changes needed here).""" - if isinstance(item, Reference): - ref_str = item.ref - try: - if not ref_str.startswith("#/"): - raise ValueError( - f"External or non-local reference not supported: {ref_str}" - ) - parts = ref_str.strip("#/").split("/") - target = openapi - for part in parts: - if part.isdigit() and isinstance(target, list): - target = target[int(part)] - elif isinstance(target, BaseModel): - # Use model_extra for fields not explicitly defined (like components types) - # Check class fields first, then model_extra - if part in target.__class__.model_fields: - target = getattr(target, part, None) - elif target.model_extra and part in target.model_extra: - target = target.model_extra[part] - else: - # Special handling for components sub-types common structure - if part == "components" and hasattr(target, "components"): - target = getattr(target, "components") - elif hasattr(target, part): # Fallback check - target = getattr(target, part, None) - else: - target = None # Part not found - elif isinstance(target, dict): - target = target.get(part) - else: - raise ValueError( - f"Cannot traverse part '{part}' in reference '{ref_str}' from type {type(target)}" - ) - if target is None: - raise ValueError( - f"Reference part '{part}' not found in path '{ref_str}'" - ) - if isinstance(target, Reference): - return _resolve_ref(target, openapi) - return target - except (AttributeError, KeyError, IndexError, TypeError, ValueError) as e: - raise ValueError(f"Failed to resolve reference '{ref_str}': {e}") from e - return item - - -# --- Main Parsing Function --- -# (No changes needed in the main loop logic, only in the helpers it calls) def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute]: """ Parses an OpenAPI schema dictionary into a list of HTTPRoute objects @@ -172,7 +125,16 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute logger.info( f"Successfully parsed OpenAPI 3.0 schema version: {openapi_30.openapi}" ) - parser = OpenAPI30Parser(openapi_30) + parser = OpenAPIParser( + openapi_30, + Reference_30, + Schema_30, + Parameter_30, + RequestBody_30, + Response_30, + Operation_30, + PathItem_30, + ) return parser.parse() else: # Default to OpenAPI 3.1 models @@ -180,7 +142,16 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute logger.info( f"Successfully parsed OpenAPI 3.1 schema version: {openapi_31.openapi}" ) - parser = OpenAPI31Parser(openapi_31) + parser = OpenAPIParser( + openapi_31, + Reference, + Schema, + Parameter, + RequestBody, + Response, + Operation, + PathItem, + ) return parser.parse() except ValidationError as e: logger.error(f"OpenAPI schema validation failed: {e}") @@ -189,151 +160,72 @@ def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute raise ValueError(f"Invalid OpenAPI schema: {error_details}") from e -# Base parser class for shared functionality -class BaseOpenAPIParser: - """Base class for OpenAPI parsers with common functionality.""" +class OpenAPIParser( + Generic[ + TOpenAPI, + TReference, + TSchema, + TParameter, + TRequestBody, + TResponse, + TOperation, + TPathItem, + ] +): + """Unified parser for OpenAPI schemas with generic type parameters to handle both 3.0 and 3.1.""" + + def __init__( + self, + openapi: TOpenAPI, + reference_cls: type[TReference], + schema_cls: type[TSchema], + parameter_cls: type[TParameter], + request_body_cls: type[TRequestBody], + response_cls: type[TResponse], + operation_cls: type[TOperation], + path_item_cls: type[TPathItem], + ): + """Initialize the parser with the OpenAPI schema and type classes.""" + self.openapi = openapi + self.reference_cls = reference_cls + self.schema_cls = schema_cls + self.parameter_cls = parameter_cls + self.request_body_cls = request_body_cls + self.response_cls = response_cls + self.operation_cls = operation_cls + self.path_item_cls = path_item_cls def _convert_to_parameter_location(self, param_in: str) -> ParameterLocation: """Convert string parameter location to our ParameterLocation type.""" - if param_in == "path": - return "path" - elif param_in == "query": - return "query" - elif param_in == "header": - return "header" - elif param_in == "cookie": - return "cookie" - else: - logger.warning( - f"Unknown parameter location: {param_in}, defaulting to 'query'" - ) - return "query" + if param_in in ["path", "query", "header", "cookie"]: + return param_in # type: ignore[return-value] # Safe cast since we checked values + logger.warning(f"Unknown parameter location: {param_in}, defaulting to 'query'") + return "query" # type: ignore[return-value] # Safe cast to default value - -class OpenAPI31Parser(BaseOpenAPIParser): - """Parser for OpenAPI 3.1 schemas.""" - - def __init__(self, openapi: OpenAPI): - self.openapi = openapi - - def parse(self) -> list[HTTPRoute]: - """Parse an OpenAPI 3.1 schema into HTTP routes.""" - routes: list[HTTPRoute] = [] - - if not self.openapi.paths: - logger.warning("OpenAPI schema has no paths defined.") - return [] - - # Extract component schemas to add to each route - schema_definitions = {} - if hasattr(self.openapi, "components") and self.openapi.components: - components = self.openapi.components - if hasattr(components, "schemas") and components.schemas: - for name, schema in components.schemas.items(): - try: - if isinstance(schema, Reference): - resolved_schema = self._resolve_ref(schema) - schema_definitions[name] = self._extract_schema_as_dict( - resolved_schema - ) - else: - schema_definitions[name] = self._extract_schema_as_dict( - schema - ) - except Exception as e: - logger.warning( - f"Failed to extract schema definition '{name}': {e}" - ) - - for path_str, path_item_obj in self.openapi.paths.items(): - if not isinstance(path_item_obj, PathItem): - logger.warning( - f"Skipping invalid path item object for path '{path_str}' (type: {type(path_item_obj)})" - ) - continue - - path_level_params = path_item_obj.parameters - - # Iterate through possible HTTP methods defined in the PathItem model fields - # Use model_fields from the class, not the instance - for method_lower in PathItem.model_fields.keys(): - if method_lower not in [ - "get", - "put", - "post", - "delete", - "options", - "head", - "patch", - "trace", - ]: - continue - - operation: Operation | None = getattr(path_item_obj, method_lower, None) - - if operation and isinstance(operation, Operation): - method_upper = cast(HttpMethod, method_lower.upper()) - logger.debug(f"Processing operation: {method_upper} {path_str}") - try: - parameters = self._extract_parameters( - operation.parameters, path_level_params - ) - request_body_info = self._extract_request_body( - operation.requestBody - ) - responses = self._extract_responses(operation.responses) - - route = HTTPRoute( - path=path_str, - method=method_upper, - operation_id=operation.operationId, - summary=operation.summary, - description=operation.description, - tags=operation.tags or [], - parameters=parameters, - request_body=request_body_info, - responses=responses, - schema_definitions=schema_definitions, - ) - routes.append(route) - logger.info( - f"Successfully extracted route: {method_upper} {path_str}" - ) - except Exception as op_error: - op_id = operation.operationId or "unknown" - logger.error( - f"Failed to process operation {method_upper} {path_str} (ID: {op_id}): {op_error}", - exc_info=True, - ) - - logger.info(f"Finished parsing. Extracted {len(routes)} HTTP routes.") - return routes - - def _resolve_ref( - self, item: Reference | Schema | Parameter | RequestBody | Any - ) -> Any: - """Resolves a potential Reference object to its target definition.""" - if isinstance(item, Reference): + def _resolve_ref(self, item: Any) -> Any: + """Resolves a reference to its target definition.""" + if isinstance(item, self.reference_cls): ref_str = item.ref try: if not ref_str.startswith("#/"): raise ValueError( f"External or non-local reference not supported: {ref_str}" ) + parts = ref_str.strip("#/").split("/") target = self.openapi + for part in parts: if part.isdigit() and isinstance(target, list): target = target[int(part)] elif isinstance(target, BaseModel): - # Use model_extra for fields not explicitly defined (like components types) # Check class fields first, then model_extra if part in target.__class__.model_fields: target = getattr(target, part, None) elif target.model_extra and part in target.model_extra: target = target.model_extra[part] else: - # Special handling for components sub-types common structure + # Special handling for components if part == "components" and hasattr(target, "components"): target = getattr(target, "components") elif hasattr(target, part): # Fallback check @@ -344,123 +236,123 @@ class OpenAPI31Parser(BaseOpenAPIParser): target = target.get(part) else: raise ValueError( - f"Cannot traverse part '{part}' in reference '{ref_str}' from type {type(target)}" + f"Cannot traverse part '{part}' in reference '{ref_str}'" ) + if target is None: raise ValueError( f"Reference part '{part}' not found in path '{ref_str}'" ) - if isinstance(target, Reference): + + # Handle nested references + if isinstance(target, self.reference_cls): return self._resolve_ref(target) + return target except (AttributeError, KeyError, IndexError, TypeError, ValueError) as e: raise ValueError(f"Failed to resolve reference '{ref_str}': {e}") from e + return item - def _extract_schema_as_dict(self, schema_obj: Schema | Reference) -> JsonSchema: - """Resolves a schema/reference and returns it as a dictionary.""" - resolved_schema = self._resolve_ref(schema_obj) - if isinstance(resolved_schema, Schema): - # Using exclude_none=True might be better than exclude_unset sometimes - return resolved_schema.model_dump( - mode="json", by_alias=True, exclude_none=True - ) - elif isinstance(resolved_schema, dict): - logger.warning( - "Resolved schema reference resulted in a dict, not a Schema model." - ) - return resolved_schema - else: - ref_str = getattr(schema_obj, "ref", "unknown") - logger.warning( - f"Expected Schema after resolving ref '{ref_str}', got {type(resolved_schema)}. Returning empty dict." - ) + def _extract_schema_as_dict(self, schema_obj: Any) -> JsonSchema: + """Resolves a schema and returns it as a dictionary.""" + try: + resolved_schema = self._resolve_ref(schema_obj) + + if isinstance(resolved_schema, (self.schema_cls)): + # Convert schema to dictionary + return resolved_schema.model_dump( + mode="json", by_alias=True, exclude_none=True + ) + elif isinstance(resolved_schema, dict): + return resolved_schema + else: + logger.warning( + f"Expected Schema after resolving, got {type(resolved_schema)}. Returning empty dict." + ) + return {} + except Exception as e: + logger.error(f"Failed to extract schema as dict: {e}", exc_info=False) return {} def _extract_parameters( self, - operation_params: list[Parameter | Reference] | None, - path_item_params: list[Parameter | Reference] | None, + operation_params: list[Any] | None = None, + path_item_params: list[Any] | None = None, ) -> list[ParameterInfo]: - """Extracts and resolves parameters using corrected attribute names.""" + """Extract and resolve parameters from operation and path item.""" extracted_params: list[ParameterInfo] = [] seen_params: dict[ tuple[str, str], bool - ] = {} # Use string keys to avoid type issues - all_params_refs = (operation_params or []) + (path_item_params or []) + ] = {} # Use tuple of (name, location) as key + all_params = (operation_params or []) + (path_item_params or []) - for param_or_ref in all_params_refs: + for param_or_ref in all_params: try: - parameter = cast(Parameter, self._resolve_ref(param_or_ref)) - if not isinstance(parameter, Parameter): - # ... (error logging remains the same) + parameter = self._resolve_ref(param_or_ref) + + if not isinstance(parameter, self.parameter_cls): + logger.warning( + f"Expected Parameter after resolving, got {type(parameter)}. Skipping." + ) continue - # --- *** CORRECTED ATTRIBUTE ACCESS HERE *** --- - param_in = parameter.param_in # CORRECTED: Use 'param_in' + # Extract parameter info - handle both 3.0 and 3.1 parameter models + param_in = parameter.param_in # Both use param_in param_location = self._convert_to_parameter_location(param_in) - param_schema_obj = ( - parameter.param_schema - ) # CORRECTED: Use 'param_schema' - # --- *** --- + param_schema_obj = parameter.param_schema # Both use param_schema + # Skip duplicate parameters (same name and location) param_key = (parameter.name, param_in) if param_key in seen_params: continue seen_params[param_key] = True + # Extract schema param_schema_dict = {} - if param_schema_obj: # Check if schema exists - # Resolve the schema if it's a reference - resolved_schema = self._resolve_ref(param_schema_obj) + if param_schema_obj: + # Process schema object param_schema_dict = self._extract_schema_as_dict(param_schema_obj) - # Ensure default value is preserved from resolved schema + # Handle default value + resolved_schema = self._resolve_ref(param_schema_obj) if ( - not isinstance(resolved_schema, Reference) + not isinstance(resolved_schema, self.reference_cls) and hasattr(resolved_schema, "default") and resolved_schema.default is not None ): param_schema_dict["default"] = resolved_schema.default - elif parameter.content: - # Handle complex parameters with 'content' + + elif hasattr(parameter, "content") and parameter.content: + # Handle content-based parameters first_media_type = next(iter(parameter.content.values()), None) if ( - first_media_type and first_media_type.media_type_schema - ): # CORRECTED: Use 'media_type_schema' - # Resolve the schema if it's a reference + first_media_type + and hasattr(first_media_type, "media_type_schema") + and first_media_type.media_type_schema + ): media_schema = first_media_type.media_type_schema - resolved_media_schema = self._resolve_ref(media_schema) param_schema_dict = self._extract_schema_as_dict(media_schema) - # Ensure default value is preserved from resolved schema + # Handle default value in content schema + resolved_media_schema = self._resolve_ref(media_schema) if ( - not isinstance(resolved_media_schema, Reference) + not isinstance(resolved_media_schema, self.reference_cls) and hasattr(resolved_media_schema, "default") and resolved_media_schema.default is not None ): param_schema_dict["default"] = resolved_media_schema.default - logger.debug( - f"Parameter '{parameter.name}' using schema from 'content' field." - ) - - # Manually create ParameterInfo instance using correct field names + # Create parameter info object param_info = ParameterInfo( name=parameter.name, - location=param_location, # Use converted parameter location + location=param_location, required=parameter.required, - schema=param_schema_dict, # Populate 'schema' field in IR + schema=param_schema_dict, description=parameter.description, ) extracted_params.append(param_info) - - except ( - ValidationError, - ValueError, - AttributeError, - TypeError, - ) as e: # Added TypeError + except Exception as e: param_name = getattr( param_or_ref, "name", getattr(param_or_ref, "ref", "unknown") ) @@ -470,52 +362,48 @@ class OpenAPI31Parser(BaseOpenAPIParser): return extracted_params - def _extract_request_body( - self, request_body_or_ref: RequestBody | Reference | None - ) -> RequestBodyInfo | None: - """Extracts and resolves the request body using corrected attribute names.""" + def _extract_request_body(self, request_body_or_ref: Any) -> RequestBodyInfo | None: + """Extract and resolve request body information.""" if not request_body_or_ref: return None + try: - request_body = cast(RequestBody, self._resolve_ref(request_body_or_ref)) - if not isinstance(request_body, RequestBody): - # ... (error logging remains the same) + request_body = self._resolve_ref(request_body_or_ref) + + if not isinstance(request_body, self.request_body_cls): + logger.warning( + f"Expected RequestBody after resolving, got {type(request_body)}. Returning None." + ) return None - content_schemas: dict[str, JsonSchema] = {} - if request_body.content: + # Create request body info + request_body_info = RequestBodyInfo( + required=request_body.required, + description=request_body.description, + ) + + # Extract content schemas + if hasattr(request_body, "content") and request_body.content: for media_type_str, media_type_obj in request_body.content.items(): - # --- *** CORRECTED ATTRIBUTE ACCESS HERE *** --- if ( - isinstance(media_type_obj, MediaType) + media_type_obj + and hasattr(media_type_obj, "media_type_schema") and media_type_obj.media_type_schema - ): # CORRECTED: Use 'media_type_schema' - # --- *** --- + ): try: - # Use the corrected attribute here as well schema_dict = self._extract_schema_as_dict( media_type_obj.media_type_schema ) - content_schemas[media_type_str] = schema_dict - except ValueError as schema_err: - logger.error( - f"Failed to extract schema for media type '{media_type_str}' in request body: {schema_err}" + request_body_info.content_schema[media_type_str] = ( + schema_dict + ) + except Exception as e: + logger.error( + f"Failed to extract schema for media type '{media_type_str}': {e}" ) - elif not isinstance(media_type_obj, MediaType): - logger.warning( - f"Skipping invalid media type object for '{media_type_str}' (type: {type(media_type_obj)}) in request body." - ) - elif not media_type_obj.media_type_schema: # Corrected check - logger.warning( - f"Skipping media type '{media_type_str}' in request body because it lacks a schema." - ) - return RequestBodyInfo( - required=request_body.required, - content_schema=content_schemas, - description=request_body.description, - ) - except (ValidationError, ValueError, AttributeError) as e: + return request_body_info + except Exception as e: ref_name = getattr(request_body_or_ref, "ref", "unknown") logger.error( f"Failed to extract request body '{ref_name}': {e}", exc_info=False @@ -523,47 +411,49 @@ class OpenAPI31Parser(BaseOpenAPIParser): return None def _extract_responses( - self, - operation_responses: dict[str, Response | Reference] | None, + self, operation_responses: dict[str, Any] | None ) -> dict[str, ResponseInfo]: - """Extracts and resolves response information for an operation.""" + """Extract and resolve response information.""" extracted_responses: dict[str, ResponseInfo] = {} + if not operation_responses: return extracted_responses for status_code, resp_or_ref in operation_responses.items(): try: - response = cast(Response, self._resolve_ref(resp_or_ref)) - if not isinstance(response, Response): - ref_str = getattr(resp_or_ref, "ref", "unknown") + response = self._resolve_ref(resp_or_ref) + + if not isinstance(response, self.response_cls): logger.warning( - f"Expected Response after resolving ref '{ref_str}' for status code {status_code}, got {type(response)}. Skipping." + f"Expected Response after resolving for status code {status_code}, " + f"got {type(response)}. Skipping." ) continue - content_schemas: dict[str, JsonSchema] = {} - if response.content: + # Create response info + resp_info = ResponseInfo(description=response.description) + + # Extract content schemas + if hasattr(response, "content") and response.content: for media_type_str, media_type_obj in response.content.items(): if ( - isinstance(media_type_obj, MediaType) + media_type_obj + and hasattr(media_type_obj, "media_type_schema") and media_type_obj.media_type_schema ): try: schema_dict = self._extract_schema_as_dict( media_type_obj.media_type_schema ) - content_schemas[media_type_str] = schema_dict - except ValueError as schema_err: + resp_info.content_schema[media_type_str] = schema_dict + except Exception as e: logger.error( - f"Failed to extract schema for media type '{media_type_str}' in response {status_code}: {schema_err}" + f"Failed to extract schema for media type '{media_type_str}' " + f"in response {status_code}: {e}" ) - resp_info = ResponseInfo( - description=response.description, content_schema=content_schemas - ) extracted_responses[str(status_code)] = resp_info - - except (ValidationError, ValueError, AttributeError) as e: + except Exception as e: ref_name = getattr(resp_or_ref, "ref", "unknown") logger.error( f"Failed to extract response for status code {status_code} " @@ -573,29 +463,22 @@ class OpenAPI31Parser(BaseOpenAPIParser): return extracted_responses - -class OpenAPI30Parser(BaseOpenAPIParser): - """Parser for OpenAPI 3.0 schemas.""" - - def __init__(self, openapi: OpenAPI_30): - self.openapi = openapi - def parse(self) -> list[HTTPRoute]: - """Parse an OpenAPI 3.0 schema into HTTP routes.""" + """Parse the OpenAPI schema into HTTP routes.""" routes: list[HTTPRoute] = [] - if not self.openapi.paths: + if not hasattr(self.openapi, "paths") or not self.openapi.paths: logger.warning("OpenAPI schema has no paths defined.") return [] - # Extract component schemas to add to each route + # Extract component schemas schema_definitions = {} if hasattr(self.openapi, "components") and self.openapi.components: components = self.openapi.components if hasattr(components, "schemas") and components.schemas: for name, schema in components.schemas.items(): try: - if isinstance(schema, Reference_30): + if isinstance(schema, self.reference_cls): resolved_schema = self._resolve_ref(schema) schema_definitions[name] = self._extract_schema_as_dict( resolved_schema @@ -609,53 +492,58 @@ class OpenAPI30Parser(BaseOpenAPIParser): f"Failed to extract schema definition '{name}': {e}" ) + # Process paths and operations for path_str, path_item_obj in self.openapi.paths.items(): - if not isinstance(path_item_obj, PathItem_30): + if not isinstance(path_item_obj, self.path_item_cls): logger.warning( - f"Skipping invalid path item object for path '{path_str}' (type: {type(path_item_obj)})" + f"Skipping invalid path item for path '{path_str}' (type: {type(path_item_obj)})" ) continue - path_level_params = path_item_obj.parameters + path_level_params = ( + path_item_obj.parameters + if hasattr(path_item_obj, "parameters") + else None + ) - # Iterate through possible HTTP methods defined in the PathItem model fields - # Use model_fields from the class, not the instance - for method_lower in PathItem_30.model_fields.keys(): - if method_lower not in [ - "get", - "put", - "post", - "delete", - "options", - "head", - "patch", - "trace", - ]: - continue + # Get HTTP methods from the path item class fields + http_methods = [ + "get", + "put", + "post", + "delete", + "options", + "head", + "patch", + "trace", + ] + for method_lower in http_methods: + operation = getattr(path_item_obj, method_lower, None) - operation: Operation_30 | None = getattr( - path_item_obj, method_lower, None - ) + if operation and isinstance(operation, self.operation_cls): + # Cast method to HttpMethod - safe since we only use valid HTTP methods + method_upper = method_lower.upper() - if operation and isinstance(operation, Operation_30): - method_upper = cast(HttpMethod, method_lower.upper()) - logger.debug(f"Processing operation: {method_upper} {path_str}") try: parameters = self._extract_parameters( - operation.parameters, path_level_params + getattr(operation, "parameters", None), path_level_params ) + request_body_info = self._extract_request_body( - operation.requestBody + getattr(operation, "requestBody", None) + ) + + responses = self._extract_responses( + getattr(operation, "responses", None) ) - responses = self._extract_responses(operation.responses) route = HTTPRoute( path=path_str, - method=method_upper, - operation_id=operation.operationId, - summary=operation.summary, - description=operation.description, - tags=operation.tags or [], + method=method_upper, # type: ignore[arg-type] # Known valid HTTP method + operation_id=getattr(operation, "operationId", None), + summary=getattr(operation, "summary", None), + description=getattr(operation, "description", None), + tags=getattr(operation, "tags", []) or [], parameters=parameters, request_body=request_body_info, responses=responses, @@ -666,7 +554,7 @@ class OpenAPI30Parser(BaseOpenAPIParser): f"Successfully extracted route: {method_upper} {path_str}" ) except Exception as op_error: - op_id = operation.operationId or "unknown" + op_id = getattr(operation, "operationId", "unknown") logger.error( f"Failed to process operation {method_upper} {path_str} (ID: {op_id}): {op_error}", exc_info=True, @@ -675,257 +563,6 @@ class OpenAPI30Parser(BaseOpenAPIParser): logger.info(f"Finished parsing. Extracted {len(routes)} HTTP routes.") return routes - def _resolve_ref( - self, item: Reference_30 | Schema_30 | Parameter_30 | RequestBody_30 | Any - ) -> Any: - """Resolves a potential Reference object to its target definition for OpenAPI 3.0.""" - if isinstance(item, Reference_30): - ref_str = item.ref - try: - if not ref_str.startswith("#/"): - raise ValueError( - f"External or non-local reference not supported: {ref_str}" - ) - parts = ref_str.strip("#/").split("/") - target = self.openapi - for part in parts: - if part.isdigit() and isinstance(target, list): - target = target[int(part)] - elif isinstance(target, BaseModel): - # Use model_extra for fields not explicitly defined (like components types) - # Check class fields first, then model_extra - if part in target.__class__.model_fields: - target = getattr(target, part, None) - elif target.model_extra and part in target.model_extra: - target = target.model_extra[part] - else: - # Special handling for components sub-types common structure - if part == "components" and hasattr(target, "components"): - target = getattr(target, "components") - elif hasattr(target, part): # Fallback check - target = getattr(target, part, None) - else: - target = None # Part not found - elif isinstance(target, dict): - target = target.get(part) - else: - raise ValueError( - f"Cannot traverse part '{part}' in reference '{ref_str}' from type {type(target)}" - ) - if target is None: - raise ValueError( - f"Reference part '{part}' not found in path '{ref_str}'" - ) - if isinstance(target, Reference_30): - return self._resolve_ref(target) - return target - except (AttributeError, KeyError, IndexError, TypeError, ValueError) as e: - raise ValueError(f"Failed to resolve reference '{ref_str}': {e}") from e - return item - - def _extract_schema_as_dict( - self, schema_obj: Schema_30 | Reference_30 - ) -> JsonSchema: - """Resolves a schema/reference and returns it as a dictionary for OpenAPI 3.0.""" - resolved_schema = self._resolve_ref(schema_obj) - if isinstance(resolved_schema, Schema_30): - # Using exclude_none=True might be better than exclude_unset sometimes - return resolved_schema.model_dump( - mode="json", by_alias=True, exclude_none=True - ) - elif isinstance(resolved_schema, dict): - logger.warning( - "Resolved schema reference resulted in a dict, not a Schema model." - ) - return resolved_schema - else: - ref_str = getattr(schema_obj, "ref", "unknown") - logger.warning( - f"Expected Schema after resolving ref '{ref_str}', got {type(resolved_schema)}. Returning empty dict." - ) - return {} - - def _extract_parameters( - self, - operation_params: list[Parameter_30 | Reference_30] | None, - path_item_params: list[Parameter_30 | Reference_30] | None, - ) -> list[ParameterInfo]: - """Extracts and resolves parameters for OpenAPI 3.0.""" - extracted_params: list[ParameterInfo] = [] - seen_params: dict[ - tuple[str, str], bool - ] = {} # Use string keys to avoid type issues - all_params_refs = (operation_params or []) + (path_item_params or []) - - for param_or_ref in all_params_refs: - try: - parameter = cast(Parameter_30, self._resolve_ref(param_or_ref)) - if not isinstance(parameter, Parameter_30): - logger.warning( - f"Expected Parameter after resolving reference, got {type(parameter)}. Skipping." - ) - continue - - # OpenAPI 3.0 uses 'in' field for parameter location - param_in = parameter.param_in - param_location = self._convert_to_parameter_location(param_in) - param_schema_obj = parameter.param_schema - - param_key = (parameter.name, param_in) - if param_key in seen_params: - continue - seen_params[param_key] = True - - param_schema_dict = {} - if param_schema_obj: # Check if schema exists - # Resolve the schema if it's a reference - resolved_schema = self._resolve_ref(param_schema_obj) - param_schema_dict = self._extract_schema_as_dict(param_schema_obj) - - # Ensure default value is preserved from resolved schema - if ( - not isinstance(resolved_schema, Reference_30) - and hasattr(resolved_schema, "default") - and resolved_schema.default is not None - ): - param_schema_dict["default"] = resolved_schema.default - elif parameter.content: - # Handle complex parameters with 'content' - first_media_type = next(iter(parameter.content.values()), None) - if first_media_type and first_media_type.media_type_schema: - # Resolve the schema if it's a reference - media_schema = first_media_type.media_type_schema - resolved_media_schema = self._resolve_ref(media_schema) - param_schema_dict = self._extract_schema_as_dict(media_schema) - - # Ensure default value is preserved from resolved schema - if ( - not isinstance(resolved_media_schema, Reference_30) - and hasattr(resolved_media_schema, "default") - and resolved_media_schema.default is not None - ): - param_schema_dict["default"] = resolved_media_schema.default - - logger.debug( - f"Parameter '{parameter.name}' using schema from 'content' field." - ) - - # Manually create ParameterInfo instance using correct field names - param_info = ParameterInfo( - name=parameter.name, - location=param_location, # Use converted parameter location - required=parameter.required, - schema=param_schema_dict, # Populate 'schema' field in IR - description=parameter.description, - ) - extracted_params.append(param_info) - - except ( - ValidationError, - ValueError, - AttributeError, - TypeError, - ) as e: # Added TypeError - param_name = getattr( - param_or_ref, "name", getattr(param_or_ref, "ref", "unknown") - ) - logger.error( - f"Failed to extract parameter '{param_name}': {e}", exc_info=False - ) - - return extracted_params - - def _extract_request_body( - self, request_body_or_ref: RequestBody_30 | Reference_30 | None - ) -> RequestBodyInfo | None: - """Extracts request body information for OpenAPI 3.0 using correct attribute names.""" - if request_body_or_ref is None: - return None - - try: - request_body = cast(RequestBody_30, self._resolve_ref(request_body_or_ref)) - - if not isinstance(request_body, RequestBody_30): - logger.warning( - f"Expected RequestBody after resolving reference, got {type(request_body)}. Returning None." - ) - return None - - request_body_info = RequestBodyInfo( - required=request_body.required, - description=request_body.description, - ) - - # Process content field for request body schemas - if request_body.content: - for media_type_key, media_type_obj in request_body.content.items(): - if ( - media_type_obj and media_type_obj.media_type_schema - ): # CORRECTED: Use 'media_type_schema' - schema_dict = self._extract_schema_as_dict( - media_type_obj.media_type_schema - ) - request_body_info.content_schema[media_type_key] = schema_dict - - return request_body_info - - except (ValidationError, ValueError, AttributeError) as e: - ref_str = getattr(request_body_or_ref, "ref", "unknown") - logger.error( - f"Failed to extract request body info from reference '{ref_str}': {e}", - exc_info=False, - ) - return None - - def _extract_responses( - self, - operation_responses: dict[str, Response_30 | Reference_30] | None, - ) -> dict[str, ResponseInfo]: - """Extracts response information from an OpenAPI 3.0 operation's responses.""" - extracted_responses: dict[str, ResponseInfo] = {} - if not operation_responses: - return extracted_responses - - for status_code, response_or_ref in operation_responses.items(): - try: - # Skip 'default' response for simplicity if needed - # if status_code == "default": - # continue - - response = cast(Response_30, self._resolve_ref(response_or_ref)) - - if not isinstance(response, Response_30): - logger.warning( - f"Expected Response after resolving reference for status code {status_code}, " - f"got {type(response)}. Skipping." - ) - continue - - response_info = ResponseInfo(description=response.description) - - # Extract content schemas if present - if response.content: - for media_type_key, media_type_obj in response.content.items(): - if ( - media_type_obj and media_type_obj.media_type_schema - ): # CORRECTED: Use 'media_type_schema' - schema_dict = self._extract_schema_as_dict( - media_type_obj.media_type_schema - ) - response_info.content_schema[media_type_key] = schema_dict - - extracted_responses[status_code] = response_info - - except (ValidationError, ValueError, AttributeError) as e: - ref_str = getattr(response_or_ref, "ref", "unknown") - logger.error( - f"Failed to extract response info for status code {status_code} " - f"from reference '{ref_str}': {e}", - exc_info=False, - ) - - return extracted_responses - def clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None: """ @@ -956,6 +593,7 @@ def clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None: # "multipleOf", "minItems", "maxItems", "uniqueItems", # "minProperties", "maxProperties" ] + for field in fields_to_remove: if field in cleaned: cleaned.pop(field) @@ -985,11 +623,6 @@ def clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None: # Maybe keep 'true' or represent as 'Allows additional properties' text? pass # Keep simple boolean for now - # Remove title if it just repeats the property name (heuristic) - # This requires knowing the property name, so better done when formatting properties dict - - return cleaned - def generate_example_from_schema(schema: JsonSchema | None) -> Any: """ @@ -1088,8 +721,8 @@ def format_description_with_responses( responses: dict[ str, Any ], # Changed from specific ResponseInfo type to avoid circular imports - parameters: list[openapi.ParameterInfo] | None = None, # Add parameters parameter - request_body: openapi.RequestBodyInfo | None = None, # Add request_body parameter + parameters: list[ParameterInfo] | None = None, # Add parameters parameter + request_body: RequestBodyInfo | None = None, # Add request_body parameter ) -> str: """ Formats the base description string with response, parameter, and request body information. @@ -1097,10 +730,10 @@ def format_description_with_responses( Args: base_description (str): The initial description to be formatted. responses (dict[str, Any]): A dictionary of response information, keyed by status code. - parameters (list[openapi.ParameterInfo] | None, optional): A list of parameter information, + parameters (list[ParameterInfo] | None, optional): A list of parameter information, including path and query parameters. Each parameter includes details such as name, location, whether it is required, and a description. - request_body (openapi.RequestBodyInfo | None, optional): Information about the request body, + request_body (RequestBodyInfo | None, optional): Information about the request body, including its description, whether it is required, and its content schema. Returns: @@ -1239,7 +872,7 @@ def format_description_with_responses( return "\n".join(desc_parts) -def _combine_schemas(route: openapi.HTTPRoute) -> dict[str, Any]: +def _combine_schemas(route: HTTPRoute) -> dict[str, Any]: """ Combines parameter and request body schemas into a single schema. @@ -1308,8 +941,6 @@ def _combine_schemas(route: openapi.HTTPRoute) -> dict[str, Any]: result["$defs"] = route.schema_definitions # Use compress_schema to remove unused definitions - from fastmcp.utilities.json_schema import compress_schema - result = compress_schema(result) return result diff --git a/tests/server/test_openapi_path_parameters.py b/tests/server/test_openapi_path_parameters.py index 2db4fa2bc..0e59aad8a 100644 --- a/tests/server/test_openapi_path_parameters.py +++ b/tests/server/test_openapi_path_parameters.py @@ -188,3 +188,73 @@ async def test_integration_array_path_parameter(array_path_spec, mock_client): json=None, timeout=None, ) + + +@pytest.mark.asyncio +async def test_complex_nested_array_path_parameter(mock_client): + """Test handling of complex nested array path parameters.""" + # Create a route with a path parameter that contains nested objects in an array + route = HTTPRoute( + path="/report/{filters}", + method="GET", + operation_id="test-complex-filters", + parameters=[ + ParameterInfo( + name="filters", + location="path", + required=True, + schema={ + "type": "array", + "items": { + "type": "object", + "properties": { + "field": {"type": "string"}, + "value": {"type": "string"}, + }, + }, + }, + ) + ], + ) + + # Create the tool + tool = OpenAPITool( + client=mock_client, + route=route, + name="test-complex-filters", + description="Test operation with complex filters", + parameters={}, + ) + + # Test with a more complex path parameter + # This would typically be serialized as JSON or a more complex format + # But for path parameters with style=simple, it should be comma-separated + complex_filters = [ + {"field": "status", "value": "active"}, + {"field": "type", "value": "user"}, + ] + + # Execute the request with complex filters + await tool._execute_request(filters=complex_filters) + + # The complex object should be properly serialized in the URL + # For path parameters, this would typically need a custom serialization strategy + # but our implementation should handle it safely + call_args = mock_client.request.call_args + + # Verify the request was made + assert call_args is not None, "The request was not made" + + # Get the called URL and verify it contains the serialized path parameter + called_url = call_args[1].get("url") + + # Check that the path parameter is handled (we don't expect perfect serialization, + # but it should not cause errors and should maintain the array structure) + assert "/report/" in called_url, "The URL should contain the path prefix" + + # Check that it didn't just convert the objects to string representations + # that include the Python object syntax + assert "status" in called_url, "The URL should contain filter field names" + assert "active" in called_url, "The URL should contain filter values" + assert "}" not in called_url, "The URL should not contain Python object syntax" + assert "{" not in called_url, "The URL should not contain Python object syntax" From 20dd0047c7ec77e6b37ff910529a8c2e33d23420 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 20 May 2025 12:26:26 -0400 Subject: [PATCH 028/114] Update tests --- src/fastmcp/server/openapi.py | 62 +++++- tests/server/test_openapi_array_params.py | 90 -------- tests/server/test_openapi_path_parameters.py | 209 ++++++++++++++++++- 3 files changed, 261 insertions(+), 100 deletions(-) delete mode 100644 tests/server/test_openapi_array_params.py diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py index cc05c2692..e8e4e21c6 100644 --- a/src/fastmcp/server/openapi.py +++ b/src/fastmcp/server/openapi.py @@ -231,14 +231,60 @@ class OpenAPITool(Tool): path = path.replace(f"{{{param_name}}}", str(param_value)) # Prepare query parameters - filter out None and empty strings - query_params = { - p.name: kwargs.get(p.name) - for p in self._route.parameters - if p.location == "query" - and p.name in kwargs - and kwargs.get(p.name) is not None - and kwargs.get(p.name) != "" - } + query_params = {} + for p in self._route.parameters: + if ( + p.location == "query" + and p.name in kwargs + and kwargs.get(p.name) is not None + and kwargs.get(p.name) != "" + ): + param_value = kwargs.get(p.name) + + # Format array query parameters as comma-separated strings + # following OpenAPI form style (default for query parameters) + if isinstance(param_value, list) and p.schema_.get("type") == "array": + # Get explode parameter from schema, default is True for query parameters + # If explode is True, the array is serialized as separate parameters + # If explode is False, the array is serialized as a comma-separated string + explode = p.schema_.get("explode", True) + + if explode: + # When explode=True, we pass the array directly, which HTTPX will serialize + # as multiple parameters with the same name + query_params[p.name] = param_value + else: + # For arrays of simple types (strings, numbers, etc.), join with commas + if all( + isinstance(item, str | int | float | bool) + for item in param_value + ): + query_params[p.name] = ",".join(str(v) for v in param_value) + else: + # For complex types, try to create a simpler representation + try: + # Try to create a simple string representation + formatted_parts = [] + for item in param_value: + if isinstance(item, dict): + # For objects, serialize key-value pairs + item_parts = [] + for k, v in item.items(): + item_parts.append(f"{k}:{v}") + formatted_parts.append(".".join(item_parts)) + else: + formatted_parts.append(str(item)) + + query_params[p.name] = ",".join(formatted_parts) + except Exception as e: + logger.warning( + f"Failed to format complex array query parameter '{p.name}': {e}" + ) + # Fallback to string representation + query_params[p.name] = param_value + else: + # Non-array parameters are passed as is + query_params[p.name] = param_value # Prepare headers - fix typing by ensuring all values are strings headers = {} diff --git a/tests/server/test_openapi_array_params.py b/tests/server/test_openapi_array_params.py deleted file mode 100644 index b87149038..000000000 --- a/tests/server/test_openapi_array_params.py +++ /dev/null @@ -1,90 +0,0 @@ -from unittest.mock import AsyncMock, MagicMock - -import httpx -import pytest - -from fastmcp.server.openapi import OpenAPITool -from fastmcp.utilities.openapi import HTTPRoute, ParameterInfo - - -@pytest.fixture -def mock_client(): - """Create a mock httpx.AsyncClient.""" - client = AsyncMock(spec=httpx.AsyncClient) - # Set up a mock response - mock_response = MagicMock() - mock_response.json.return_value = {"result": "success"} - mock_response.raise_for_status.return_value = None - client.request.return_value = mock_response - return client - - -@pytest.mark.asyncio -async def test_array_path_parameter_handling(mock_client): - """Test how array path parameters are handled.""" - # Create a simple route with array path parameter - route = HTTPRoute( - path="/select/{days}", - method="PUT", - operation_id="test-operation", - parameters=[ - ParameterInfo( - name="days", - location="path", - required=True, - schema={ - "type": "array", - "items": { - "type": "string", - "enum": [ - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday", - "sunday", - ], - }, - }, - ) - ], - ) - - # Create the tool - tool = OpenAPITool( - client=mock_client, - route=route, - name="test-operation", - description="Test operation", - parameters={}, - ) - - # Test with a single value - await tool._execute_request(days=["monday"]) - - # Check that the path parameter is formatted correctly - # This is where the bug is: it should be '/select/monday' not '/select/[\'monday\']' - mock_client.request.assert_called_with( - method="PUT", - url="/select/monday", # This is the expected format - params={}, - headers={}, - json=None, - timeout=None, - ) - mock_client.request.reset_mock() - - # Test with multiple values - await tool._execute_request(days=["monday", "tuesday"]) - - # Check that the path parameter is formatted correctly - # It should be '/select/monday,tuesday' not '/select/[\'monday\', \'tuesday\']' - mock_client.request.assert_called_with( - method="PUT", - url="/select/monday,tuesday", # This is the expected format - params={}, - headers={}, - json=None, - timeout=None, - ) diff --git a/tests/server/test_openapi_path_parameters.py b/tests/server/test_openapi_path_parameters.py index 0e59aad8a..e8b6121e5 100644 --- a/tests/server/test_openapi_path_parameters.py +++ b/tests/server/test_openapi_path_parameters.py @@ -1,10 +1,12 @@ +from typing import Annotated, Literal from unittest.mock import AsyncMock, MagicMock import httpx import pytest +from fastapi import FastAPI, Query -from fastmcp import FastMCP -from fastmcp.server.openapi import OpenAPITool +from fastmcp import Client, FastMCP +from fastmcp.server.openapi import OpenAPITool, RouteMap, RouteType from fastmcp.utilities.openapi import HTTPRoute, ParameterInfo @@ -258,3 +260,206 @@ async def test_complex_nested_array_path_parameter(mock_client): assert "active" in called_url, "The URL should contain filter values" assert "}" not in called_url, "The URL should not contain Python object syntax" assert "{" not in called_url, "The URL should not contain Python object syntax" + + +@pytest.mark.asyncio +async def test_array_query_param_with_fastapi(): + """Test array query parameters using FastAPI and FastMCP.from_fastapi integration.""" + # Create a FastAPI app with a route that has an array query parameter + app = FastAPI() + + @app.get("/select") + async def select_days( + days: Annotated[ + list[ + Literal[ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", + ] + ], + Query(explode=True), + ], + ): # Using explode=True to get days=monday&days=tuesday format + return {"selected": days} + + # Create a FastMCP server from the FastAPI app + mcp = FastMCP.from_fastapi( + app, + route_maps=[ + RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.TOOL) + ], + ) + + # Test with the client + async with Client(mcp) as client: + # Get the actual tool name first + tools = await client.list_tools() + tool_names = [tool.name for tool in tools] + assert len(tool_names) == 1, ( + f"Expected one tool, got {len(tool_names)}: {tool_names}" + ) + tool_name = tool_names[0] + + # Single day + result = await client.call_tool(tool_name, {"days": ["monday"]}) + # Client returns TextContent objects, so parse the JSON + assert len(result) == 1 + assert result[0].type == "text" + import json + + result_data = json.loads(result[0].text) + assert result_data == {"selected": ["monday"]} + + # Multiple days + result = await client.call_tool(tool_name, {"days": ["monday", "tuesday"]}) + assert len(result) == 1 + assert result[0].type == "text" + result_data = json.loads(result[0].text) + assert result_data == {"selected": ["monday", "tuesday"]} + + +@pytest.mark.asyncio +async def test_array_query_parameter_format(mock_client): + """Test that array query parameters are formatted as comma-separated values when explode=False.""" + # Create a route with array query parameter + route = HTTPRoute( + path="/select", + method="GET", + operation_id="test-operation", + parameters=[ + ParameterInfo( + name="days", + location="query", # This is a query parameter + required=True, + schema={ + "type": "array", + "explode": False, # Set explode=False to test comma-separated formatting + "items": { + "type": "string", + "enum": [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", + ], + }, + }, + ) + ], + ) + + # Create the tool + tool = OpenAPITool( + client=mock_client, + route=route, + name="test-operation", + description="Test operation", + parameters={}, + ) + + # Test with a single value + await tool._execute_request(days=["monday"]) + + # Check that the query parameter is formatted correctly + mock_client.request.assert_called_with( + method="GET", + url="/select", + params={"days": "monday"}, # Should be formatted as a string, not a list + headers={}, + json=None, + timeout=None, + ) + mock_client.request.reset_mock() + + # Test with multiple values + await tool._execute_request(days=["monday", "tuesday"]) + + # Check that the query parameter is formatted correctly + # It should be 'days=monday,tuesday' not 'days=["monday","tuesday"]' + mock_client.request.assert_called_with( + method="GET", + url="/select", + params={"days": "monday,tuesday"}, # Should be comma-separated + headers={}, + json=None, + timeout=None, + ) + + +@pytest.mark.asyncio +async def test_array_query_parameter_exploded_format(mock_client): + """Test that array query parameters are formatted as separate parameters when explode=True.""" + # Create a route with array query parameter with explode=True (default) + route = HTTPRoute( + path="/select-exploded", + method="GET", + operation_id="test-exploded-operation", + parameters=[ + ParameterInfo( + name="days", + location="query", # This is a query parameter + required=True, + schema={ + "type": "array", + "explode": True, # Set explode=True for separate parameter serialization + "items": { + "type": "string", + "enum": [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", + ], + }, + }, + ) + ], + ) + + # Create the tool + tool = OpenAPITool( + client=mock_client, + route=route, + name="test-exploded-operation", + description="Test operation with exploded arrays", + parameters={}, + ) + + # Test with a single value + await tool._execute_request(days=["monday"]) + + # Check that the query parameter is formatted correctly + mock_client.request.assert_called_with( + method="GET", + url="/select-exploded", + params={"days": ["monday"]}, # Should be passed as a list for explode=True + headers={}, + json=None, + timeout=None, + ) + mock_client.request.reset_mock() + + # Test with multiple values + await tool._execute_request(days=["monday", "tuesday"]) + + # Check that the query parameter is formatted correctly + # It should be passed as an array, which httpx will serialize as days=monday&days=tuesday + mock_client.request.assert_called_with( + method="GET", + url="/select-exploded", + params={"days": ["monday", "tuesday"]}, # Should be passed as a list + headers={}, + json=None, + timeout=None, + ) From a39100bd05170847a1f2ec5550ea0fe77b0a2358 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 20 May 2025 13:24:44 -0400 Subject: [PATCH 029/114] Allow * Methods and all routes as tools shortcuts --- docs/patterns/openapi.mdx | 51 +++++- src/fastmcp/server/openapi.py | 4 +- src/fastmcp/server/server.py | 53 ++++++- tests/server/test_openapi.py | 284 ++++++++++++++++++++++++++++++++++ 4 files changed, 376 insertions(+), 16 deletions(-) diff --git a/docs/patterns/openapi.mdx b/docs/patterns/openapi.mdx index fb2524446..0934cec8e 100644 --- a/docs/patterns/openapi.mdx +++ b/docs/patterns/openapi.mdx @@ -61,19 +61,27 @@ Internally, FastMCP uses a priority-ordered set of `RouteMap` objects to determi # 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), + RouteMap( + methods=["GET"], + pattern=r".*\{.*\}.*", + route_type=RouteType.RESOURCE_TEMPLATE, + ), # GET without path parameters -> Resource - RouteMap(methods=["GET"], pattern=r".*", - route_type=RouteType.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), + RouteMap( + methods="*", + 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. @@ -97,6 +105,35 @@ mcp = await FastMCP.from_openapi( ) ``` + +### All Routes as Tools + +When building AI agent backends, it's often useful to treat all routes as callable tools regardless of their HTTP method. You can use the `all_routes_as_tools` parameter to automatically map every route to a Tool: + +```python +# Make all endpoints tools, regardless of HTTP method +mcp = FastMCP.from_openapi( + openapi_spec=spec, + client=api_client, + all_routes_as_tools=True +) +``` + +This is equivalent to defining a single route map that matches all routes: + +```python +# Same effect as all_routes_as_tools=True +mcp = FastMCP.from_openapi( + openapi_spec=spec, + client=api_client, + route_maps=[ + RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL) + ] +) +``` + +Note that `all_routes_as_tools` and `route_maps` cannot be used together - if you need more complex mapping rules, use `route_maps` instead. + ## How It Works 1. FastMCP parses your OpenAPI spec to extract routes and schemas diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py index e8e4e21c6..687790653 100644 --- a/src/fastmcp/server/openapi.py +++ b/src/fastmcp/server/openapi.py @@ -47,7 +47,7 @@ class RouteType(enum.Enum): class RouteMap: """Mapping configuration for HTTP routes to FastMCP component types.""" - methods: list[HttpMethod] + methods: list[HttpMethod] | Literal["*"] pattern: Pattern[str] | str route_type: RouteType @@ -86,7 +86,7 @@ def _determine_route_type( # Check mappings in priority order (first match wins) for route_map in mappings: # Check if the HTTP method matches - if route.method in route_map.methods: + if route_map.methods == "*" or route.method in route_map.methods: # Handle both string patterns and compiled Pattern objects if isinstance(route_map.pattern, Pattern): pattern_matches = route_map.pattern.search(route.path) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index d8c86eb65..1edbcac4d 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -62,7 +62,7 @@ from fastmcp.utilities.logging import get_logger if TYPE_CHECKING: from fastmcp.client import Client from fastmcp.client.transports import ClientTransport - from fastmcp.server.openapi import FastMCPOpenAPI + from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap from fastmcp.server.proxy import FastMCPProxy logger = get_logger(__name__) @@ -1082,24 +1082,59 @@ class FastMCP(Generic[LifespanResultT]): @classmethod def from_openapi( - cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, **settings: Any + cls, + openapi_spec: dict[str, Any], + client: httpx.AsyncClient, + route_maps: list[RouteMap] | None = None, + all_routes_as_tools: bool = False, + **settings: Any, ) -> FastMCPOpenAPI: """ Create a FastMCP server from an OpenAPI specification. """ - from .openapi import FastMCPOpenAPI + from .openapi import FastMCPOpenAPI, RouteMap, RouteType - return FastMCPOpenAPI(openapi_spec=openapi_spec, client=client, **settings) + if all_routes_as_tools and route_maps: + raise ValueError("Cannot specify both all_routes_as_tools and route_maps") + + elif all_routes_as_tools: + route_maps = [ + RouteMap( + methods="*", + pattern=r".*", + route_type=RouteType.TOOL, + ) + ] + + return FastMCPOpenAPI( + openapi_spec=openapi_spec, + client=client, + route_maps=route_maps, + **settings, + ) @classmethod def from_fastapi( - cls, app: Any, name: str | None = None, **settings: Any + cls, + app: Any, + name: str | None = None, + route_maps: list[RouteMap] | None = None, + all_routes_as_tools: bool = False, + **settings: Any, ) -> FastMCPOpenAPI: """ Create a FastMCP server from a FastAPI application. """ - from .openapi import FastMCPOpenAPI + from .openapi import FastMCPOpenAPI, RouteMap, RouteType + + if all_routes_as_tools and route_maps: + raise ValueError("Cannot specify both all_routes_as_tools and route_maps") + + elif all_routes_as_tools: + route_maps = [ + RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL) + ] client = httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://fastapi" @@ -1108,7 +1143,11 @@ class FastMCP(Generic[LifespanResultT]): name = name or app.title return FastMCPOpenAPI( - openapi_spec=app.openapi(), client=client, name=name, **settings + openapi_spec=app.openapi(), + client=client, + name=name, + route_maps=route_maps, + **settings, ) @classmethod diff --git a/tests/server/test_openapi.py b/tests/server/test_openapi.py index e92d33b48..7bd7d5d34 100644 --- a/tests/server/test_openapi.py +++ b/tests/server/test_openapi.py @@ -1890,3 +1890,287 @@ class TestEnumHandling: assert "enum" in enum_def assert enum_def["enum"] == ["foo", "bar", "baz"] assert enum_def["type"] == "string" + + +class TestRouteMapWildcard: + """Tests for wildcard RouteMap methods functionality.""" + + @pytest.fixture + def basic_openapi_spec(self) -> dict: + """Create a minimal OpenAPI spec with different HTTP methods.""" + return { + "openapi": "3.1.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "paths": { + "/users": { + "get": { + "operationId": "getUsers", + "responses": {"200": {"description": "Success"}}, + }, + "post": { + "operationId": "createUser", + "responses": {"201": {"description": "Created"}}, + }, + }, + "/posts": { + "get": { + "operationId": "getPosts", + "responses": {"200": {"description": "Success"}}, + }, + "post": { + "operationId": "createPost", + "responses": {"201": {"description": "Created"}}, + }, + }, + }, + } + + @pytest.fixture + async def mock_basic_client(self) -> httpx.AsyncClient: + """Create a simple mock client.""" + + async def _responder(request): + return httpx.Response(200, json={"status": "ok"}) + + transport = httpx.MockTransport(_responder) + return httpx.AsyncClient(transport=transport, base_url="http://test") + + async def test_wildcard_matches_all_methods( + self, basic_openapi_spec, mock_basic_client + ): + """Test that a RouteMap with methods='*' matches all HTTP methods.""" + # Create a single route map with wildcard method + route_maps = [RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL)] + + mcp = FastMCPOpenAPI( + openapi_spec=basic_openapi_spec, + client=mock_basic_client, + route_maps=route_maps, + ) + + # All operations should be mapped to tools + tools = mcp._tool_manager.list_tools() + tool_names = {tool.name for tool in tools} + + # Check that all operations were mapped as tools + expected_tools = {"getUsers", "createUser", "getPosts", "createPost"} + assert tool_names == expected_tools + + # No resources or templates should be created + resources = mcp._resource_manager.get_resources() + templates = mcp._resource_manager.get_templates() + assert len(resources) == 0 + assert len(templates) == 0 + + async def test_priority_specific_over_wildcard( + self, basic_openapi_spec, mock_basic_client + ): + """Test that specific method maps take priority over wildcard.""" + # Create route maps with specific method first, then wildcard + route_maps = [ + # GET operations should be mapped to resources + RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE), + # All other operations should be mapped to tools + RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL), + ] + + mcp = FastMCPOpenAPI( + openapi_spec=basic_openapi_spec, + client=mock_basic_client, + route_maps=route_maps, + ) + + # Check GET operations went to resources + resources = mcp._resource_manager.get_resources() + resource_names = {r.name for r in resources.values()} + assert "getUsers" in resource_names + assert "getPosts" in resource_names + assert len(resources) == 2 + + # Check other operations went to tools + tools = mcp._tool_manager.list_tools() + tool_names = {tool.name for tool in tools} + assert "createUser" in tool_names + assert "createPost" in tool_names + assert len(tools) == 2 + + async def test_priority_wildcard_first(self, basic_openapi_spec, mock_basic_client): + """Test that when wildcard is first, it matches everything.""" + # Create route maps with wildcard first, then specific methods + route_maps = [ + # Wildcard first matches everything + RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL), + # This should never be reached + RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE), + ] + + mcp = FastMCPOpenAPI( + openapi_spec=basic_openapi_spec, + client=mock_basic_client, + route_maps=route_maps, + ) + + # All operations should be tools + tools = mcp._tool_manager.list_tools() + assert len(tools) == 4 + + # No resources should be created + resources = mcp._resource_manager.get_resources() + assert len(resources) == 0 + + async def test_wildcard_with_specific_paths( + self, basic_openapi_spec, mock_basic_client + ): + """Test wildcard methods combined with specific path patterns.""" + route_maps = [ + # All methods on /users path -> Resources + RouteMap(methods="*", pattern=r".*/users$", route_type=RouteType.RESOURCE), + # All methods on /posts path -> Tools + RouteMap(methods="*", pattern=r".*/posts$", route_type=RouteType.TOOL), + ] + + mcp = FastMCPOpenAPI( + openapi_spec=basic_openapi_spec, + client=mock_basic_client, + route_maps=route_maps, + ) + + # Check /users operations went to resources + resources = mcp._resource_manager.get_resources() + resource_names = {r.name for r in resources.values()} + assert "getUsers" in resource_names + assert "createUser" in resource_names + assert len(resources) == 2 + + # Check /posts operations went to tools + tools = mcp._tool_manager.list_tools() + tool_names = {tool.name for tool in tools} + assert "getPosts" in tool_names + assert "createPost" in tool_names + assert len(tools) == 2 + + +class TestAllRoutesAsTools: + """Tests for the all_routes_as_tools parameter in FastMCP class methods.""" + + @pytest.fixture + def simple_api_spec(self) -> dict: + """A simple OpenAPI spec with both GET and POST methods.""" + return { + "openapi": "3.1.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "paths": { + "/items": { + "get": { + "operationId": "getItems", + "responses": {"200": {"description": "Success"}}, + }, + "post": { + "operationId": "createItem", + "responses": {"201": {"description": "Created"}}, + }, + }, + }, + } + + @pytest.fixture + async def mock_client(self) -> httpx.AsyncClient: + """Simple mock client for testing.""" + + async def _responder(request): + return httpx.Response(200, json={"result": "ok"}) + + transport = httpx.MockTransport(_responder) + return httpx.AsyncClient(transport=transport, base_url="http://test") + + async def test_from_openapi_all_routes_as_tools(self, simple_api_spec, mock_client): + """Test FastMCP.from_openapi with all_routes_as_tools=True.""" + # Create server with all routes as tools + server = FastMCP.from_openapi( + openapi_spec=simple_api_spec, client=mock_client, all_routes_as_tools=True + ) + + # All operations (GET and POST) should be mapped to tools + tools = server._tool_manager.list_tools() + tool_names = {t.name for t in tools} + + assert "getItems" in tool_names + assert "createItem" in tool_names + assert len(tools) == 2 + + # No resources or templates should be created + resources = server._resource_manager.get_resources() + templates = server._resource_manager.get_templates() + assert len(resources) == 0 + assert len(templates) == 0 + + async def test_from_openapi_all_routes_as_tools_conflicting_args( + self, simple_api_spec, mock_client + ): + """Test FastMCP.from_openapi raises error when both route_maps and all_routes_as_tools are provided.""" + # Try to create server with conflicting args + with pytest.raises( + ValueError, match="Cannot specify both all_routes_as_tools and route_maps" + ): + FastMCP.from_openapi( + openapi_spec=simple_api_spec, + client=mock_client, + all_routes_as_tools=True, + route_maps=[ + RouteMap( + methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE + ) + ], + ) + + async def test_from_fastapi_all_routes_as_tools(self): + """Test FastMCP.from_fastapi with all_routes_as_tools=True.""" + # Create a simple FastAPI app + app = FastAPI(title="Test FastAPI") + + @app.get("/items") + async def get_items(): + return [{"id": 1, "name": "Item 1"}] + + @app.post("/items") + async def create_item(item: dict): + return {"id": 2, **item} + + # Create server with all routes as tools + server = FastMCP.from_fastapi(app=app, all_routes_as_tools=True) + + # Both GET and POST operations should be mapped to tools + tools = server._tool_manager.list_tools() + + # Get tool names from the generated operation IDs + tool_names = {t.name for t in tools} + + # Check that both routes were mapped to tools + # The exact names depend on FastAPI's operation ID generation + assert len(tools) == 2 + assert any("get" in name.lower() for name in tool_names) + assert any("post" in name.lower() for name in tool_names) + + # No resources or templates should be created + resources = server._resource_manager.get_resources() + templates = server._resource_manager.get_templates() + assert len(resources) == 0 + assert len(templates) == 0 + + async def test_from_fastapi_all_routes_as_tools_conflicting_args(self): + """Test FastMCP.from_fastapi raises error when both route_maps and all_routes_as_tools are provided.""" + app = FastAPI(title="Test FastAPI") + + # Try to create server with conflicting args + with pytest.raises( + ValueError, match="Cannot specify both all_routes_as_tools and route_maps" + ): + FastMCP.from_fastapi( + app=app, + all_routes_as_tools=True, + route_maps=[ + RouteMap( + methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE + ) + ], + ) From ed73488dc9b3b614a8d36ca922f3d59ac23e8381 Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Tue, 20 May 2025 12:40:14 -0500 Subject: [PATCH 030/114] integration tests and better error --- .github/workflows/run-integration-tests.yml | 50 ++++++++++++ integration_tests/test_repro_518_output.py | 89 +++++++++++++++++++++ src/fastmcp/server/http.py | 21 ++++- 3 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/run-integration-tests.yml create mode 100644 integration_tests/test_repro_518_output.py diff --git a/.github/workflows/run-integration-tests.yml b/.github/workflows/run-integration-tests.yml new file mode 100644 index 000000000..577f6cc11 --- /dev/null +++ b/.github/workflows/run-integration-tests.yml @@ -0,0 +1,50 @@ +name: Run integration tests + +env: + # enable colored output + PY_COLORS: 1 + +on: + push: + branches: ["main"] + paths: + - "src/**" + - "integration_tests/**" + - "uv.lock" + - "pyproject.toml" + - ".github/workflows/**" + + # run on all pull requests because these checks are required and will block merges otherwise + pull_request: + + workflow_dispatch: + +permissions: + contents: read + +jobs: + run_tests: + name: "Run tests: Python ${{ matrix.python-version }} on ${{ matrix.os }}" + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + python-version: ["3.10"] + fail-fast: false + timeout-minutes: 5 + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + python-version: ${{ matrix.python-version }} + + - name: Install FastMCP + run: uv sync --locked + + - name: Run tests + run: uv run pytest integration_tests diff --git a/integration_tests/test_repro_518_output.py b/integration_tests/test_repro_518_output.py new file mode 100644 index 000000000..b82dd03ae --- /dev/null +++ b/integration_tests/test_repro_518_output.py @@ -0,0 +1,89 @@ +import os +import subprocess +import sys +import tempfile +import time + +import httpx +import pytest + +PYTHON_EXE = sys.executable + +SERVER_CODE = """ +import uvicorn +from fastapi import FastAPI +from fastmcp import FastMCP + +mcp = FastMCP() + +@mcp.tool("dummy_tool", "A simple dummy tool for the test server") +def add(a: int, b: int) -> int: + return a + b + +app = FastAPI() # Intentionally no lifespan=mcp.lifespan +app.mount("/", mcp.http_app(transport="streamable-http")) + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=8080, log_config=None) +""" + + +@pytest.mark.timeout(20) +def test_server_shows_informative_error_on_stderr(): + """ + Runs a minimal FastMCP+FastAPI server (that omits lifespan wiring) + as a subprocess, triggers the error via an HTTP request, and then checks + if the server's stderr contains the specific informative error message for issue #518. + """ + process = None + captured_stderr = "" + + with tempfile.NamedTemporaryFile( + mode="w", suffix=".py", delete=False + ) as tmp_script: + tmp_script.write(SERVER_CODE) + tmp_script_path = tmp_script.name + + try: + process = subprocess.Popen( + [PYTHON_EXE, "-u", tmp_script_path], + stderr=subprocess.PIPE, + stdout=subprocess.PIPE, + text=True, + universal_newlines=True, + ) + + time.sleep(3) + + if process.poll() is None: + try: + with httpx.Client(timeout=5.0) as client: + # The mounted FastMCP app is at root, its internal default path is /mcp + client.get("http://localhost:8080/mcp/") + except httpx.RequestError: + pass + time.sleep(1) + + finally: + if process: + if process.poll() is None: + process.terminate() + try: + _, captured_stderr = process.communicate(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + _, captured_stderr = process.communicate() + + if os.path.exists(tmp_script_path): + os.unlink(tmp_script_path) + + assert captured_stderr is not None, "stderr should have been captured" + normalized_stderr = captured_stderr.replace("\r\n", "\n").replace("\r", "\n") + + assert ( + "FastMCP's StreamableHTTPSessionManager task group was not initialized" + in normalized_stderr + ) + assert "lifespan=mcp_app.lifespan" in normalized_stderr + assert "gofastmcp.com/deployment/asgi" in normalized_stderr + assert "Original error: Task group is not initialized" in normalized_stderr diff --git a/src/fastmcp/server/http.py b/src/fastmcp/server/http.py index 8655b2d64..18c78be76 100644 --- a/src/fastmcp/server/http.py +++ b/src/fastmcp/server/http.py @@ -306,7 +306,26 @@ def create_streamable_http_app( async def handle_streamable_http( scope: Scope, receive: Receive, send: Send ) -> None: - await session_manager.handle_request(scope, receive, send) + try: + await session_manager.handle_request(scope, receive, send) + except RuntimeError as e: + if "Task group is not initialized" in str(e): + new_error_message = ( + "FastMCP's StreamableHTTPSessionManager task group was not initialized. " + "This commonly occurs when the FastMCP application's lifespan is not " + "passed to the parent ASGI application (e.g., FastAPI or Starlette). " + "Please ensure you are setting `lifespan=mcp_app.lifespan` in your " + "parent app's constructor, where `mcp_app` is the application instance " + "returned by `fastmcp_instance.http_app()`. \\n" + "For more details, see the FastMCP ASGI integration documentation: " + "https://gofastmcp.com/deployment/asgi" + ) + # Raise a new RuntimeError that includes the original error's message + # for full context, but leads with the more helpful guidance. + raise RuntimeError(f"{new_error_message}\\nOriginal error: {e}") from e + else: + # Re-raise other RuntimeErrors if they don't match the specific message + raise # Get auth middleware and routes auth_middleware, auth_routes, required_scopes = setup_auth_middleware_and_routes( From 3d220a7724272a8f30411374275d4d80746c6a3e Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Tue, 20 May 2025 12:43:31 -0500 Subject: [PATCH 031/114] more specific --- .github/workflows/run-tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index c201aca74..04f35da3e 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -44,7 +44,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install FastMCP - run: uv sync --dev --locked + run: uv sync --locked - name: Run tests - run: uv run pytest + run: uv run pytest tests From 648cbfa22244a52314535a6409bce3c0e42f4164 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 20 May 2025 14:38:10 -0400 Subject: [PATCH 032/114] Improved support for config dicts --- src/fastmcp/client/mcp_config.py | 74 ++++++++++++++++++++++++++++++++ src/fastmcp/client/transports.py | 54 +++++++++++------------ 2 files changed, 99 insertions(+), 29 deletions(-) create mode 100644 src/fastmcp/client/mcp_config.py diff --git a/src/fastmcp/client/mcp_config.py b/src/fastmcp/client/mcp_config.py new file mode 100644 index 000000000..d89eaf2f7 --- /dev/null +++ b/src/fastmcp/client/mcp_config.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Annotated, Any, Literal, TypeAlias + +from pydantic import Field +from pydantic.dataclasses import dataclass + +if TYPE_CHECKING: + from fastmcp.client.client import Client + from fastmcp.client.transports import ( + SSETransport, + StdioTransport, + StreamableHttpTransport, + ) + + +@dataclass +class LocalMCPServer: + command: str + args: list[str] + env: dict[str, Any] = Field(default_factory=dict) + cwd: str | None = None + + def to_transport(self) -> StdioTransport: + from fastmcp.client.transports import StdioTransport + + return StdioTransport( + command=self.command, + args=self.args, + env=self.env, + cwd=self.cwd, + ) + + +@dataclass +class RemoteMCPServer: + url: str + transport: Literal["http", "sse"] | None = None + headers: dict[str, str] = Field(default_factory=dict) + + def to_transport(self) -> StreamableHttpTransport | SSETransport: + from fastmcp.client.transports import SSETransport, StreamableHttpTransport + + if self.transport in {"http", None}: + return StreamableHttpTransport(self.url, headers=self.headers) + else: + return SSETransport(self.url, headers=self.headers) + + +MCPServer: TypeAlias = LocalMCPServer | RemoteMCPServer + + +@dataclass +class MCPConfig: + mcp_servers: Annotated[dict[str, MCPServer], Field(alias="mcpServers")] + + @classmethod + def from_dict(cls, config: dict[str, Any]) -> MCPConfig: + return cls(mcp_servers=config.get("mcpServers", config)) + + def to_transports( + self, + ) -> dict[str, StdioTransport | StreamableHttpTransport | SSETransport]: + return { + name: server.to_transport() for name, server in self.mcp_servers.items() + } + + def to_clients(self) -> dict[str, Client]: + from fastmcp.client.client import Client + + return { + name: Client(transport=transport) + for name, transport in self.to_transports().items() + } diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index a5f0f95e0..b30a6e0d6 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -6,7 +6,7 @@ import shutil import sys from collections.abc import AsyncIterator from pathlib import Path -from typing import Any, TypedDict, cast +from typing import TYPE_CHECKING, Any, TypedDict, cast from urllib.parse import urlparse from mcp import ClientSession, StdioServerParameters @@ -24,9 +24,13 @@ from mcp.shared.memory import create_connected_server_and_client_session from pydantic import AnyUrl from typing_extensions import Unpack +from fastmcp.client.mcp_config import MCPConfig from fastmcp.server import FastMCP as FastMCPServer from fastmcp.utilities.logging import get_logger +if TYPE_CHECKING: + from fastmcp.client.mcp_config import MCPConfig + logger = get_logger(__name__) @@ -470,7 +474,13 @@ class FastMCPTransport(ClientTransport): def infer_transport( - transport: ClientTransport | FastMCPServer | AnyUrl | Path | dict[str, Any] | str, + transport: ClientTransport + | FastMCPServer + | AnyUrl + | Path + | MCPConfig + | dict[str, Any] + | str, ) -> ClientTransport: """ Infer the appropriate transport type from the given transport argument. @@ -481,6 +491,8 @@ def infer_transport( For HTTP URLs, they are assumed to be Streamable HTTP URLs unless they end in `/sse`. """ + from fastmcp.client.mcp_config import MCPConfig + # the transport is already a ClientTransport if isinstance(transport, ClientTransport): return transport @@ -511,34 +523,18 @@ def infer_transport( else: inferred_transport = StreamableHttpTransport(url=transport) - ## if the transport is a config dict - elif isinstance(transport, dict): - if "mcpServers" not in transport: - raise ValueError("Invalid transport dictionary: missing 'mcpServers' key") + # if the transport is a config dict or MCPConfig + elif isinstance(transport, dict | MCPConfig): + if isinstance(transport, dict): + config = MCPConfig.from_dict(transport) else: - server = transport["mcpServers"] - if len(list(server.keys())) > 1: - raise ValueError( - "Invalid transport dictionary: multiple servers found - only one expected" - ) - server_name = list(server.keys())[0] - # Stdio transport - if "command" in server[server_name] and "args" in server[server_name]: - inferred_transport = StdioTransport( - command=server[server_name]["command"], - args=server[server_name]["args"], - env=server[server_name].get("env", None), - cwd=server[server_name].get("cwd", None), - ) - - # HTTP transport - elif "url" in server: - inferred_transport = SSETransport( - url=server["url"], - headers=server.get("headers", None), - ) - - raise ValueError("Cannot determine transport type from dictionary") + config = transport + inferred_transports = config.to_transports() + if len(inferred_transports) > 1: + raise ValueError( + "Invalid transport dictionary: multiple servers found - only one expected" + ) + inferred_transport = list(inferred_transports.values())[0] # the transport is an unknown type else: From 7f46580c147df9fbe8d5a80c3a1d4ab73a455cfc Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Tue, 20 May 2025 13:48:23 -0500 Subject: [PATCH 033/114] rm slop markers --- .github/workflows/run-integration-tests.yml | 50 ------ integration_tests/test_repro_518_output.py | 89 ---------- src/fastmcp/utilities/tests.py | 12 +- tests/contrib/test_bulk_tool_caller.py | 8 - tests/server/test_auth_integration.py | 29 ---- tests/server/test_http_middleware.py | 6 - tests/server/test_lifespan.py | 166 ++++++++++++++++++- tests/server/test_openapi_path_parameters.py | 6 - tests/test_deprecated.py | 2 - tests/test_examples.py | 5 - 10 files changed, 171 insertions(+), 202 deletions(-) delete mode 100644 .github/workflows/run-integration-tests.yml delete mode 100644 integration_tests/test_repro_518_output.py diff --git a/.github/workflows/run-integration-tests.yml b/.github/workflows/run-integration-tests.yml deleted file mode 100644 index 577f6cc11..000000000 --- a/.github/workflows/run-integration-tests.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Run integration tests - -env: - # enable colored output - PY_COLORS: 1 - -on: - push: - branches: ["main"] - paths: - - "src/**" - - "integration_tests/**" - - "uv.lock" - - "pyproject.toml" - - ".github/workflows/**" - - # run on all pull requests because these checks are required and will block merges otherwise - pull_request: - - workflow_dispatch: - -permissions: - contents: read - -jobs: - run_tests: - name: "Run tests: Python ${{ matrix.python-version }} on ${{ matrix.os }}" - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, windows-latest] - python-version: ["3.10"] - fail-fast: false - timeout-minutes: 5 - - steps: - - uses: actions/checkout@v4 - - - name: Install uv - uses: astral-sh/setup-uv@v5 - with: - enable-cache: true - cache-dependency-glob: "uv.lock" - python-version: ${{ matrix.python-version }} - - - name: Install FastMCP - run: uv sync --locked - - - name: Run tests - run: uv run pytest integration_tests diff --git a/integration_tests/test_repro_518_output.py b/integration_tests/test_repro_518_output.py deleted file mode 100644 index b82dd03ae..000000000 --- a/integration_tests/test_repro_518_output.py +++ /dev/null @@ -1,89 +0,0 @@ -import os -import subprocess -import sys -import tempfile -import time - -import httpx -import pytest - -PYTHON_EXE = sys.executable - -SERVER_CODE = """ -import uvicorn -from fastapi import FastAPI -from fastmcp import FastMCP - -mcp = FastMCP() - -@mcp.tool("dummy_tool", "A simple dummy tool for the test server") -def add(a: int, b: int) -> int: - return a + b - -app = FastAPI() # Intentionally no lifespan=mcp.lifespan -app.mount("/", mcp.http_app(transport="streamable-http")) - -if __name__ == "__main__": - uvicorn.run(app, host="0.0.0.0", port=8080, log_config=None) -""" - - -@pytest.mark.timeout(20) -def test_server_shows_informative_error_on_stderr(): - """ - Runs a minimal FastMCP+FastAPI server (that omits lifespan wiring) - as a subprocess, triggers the error via an HTTP request, and then checks - if the server's stderr contains the specific informative error message for issue #518. - """ - process = None - captured_stderr = "" - - with tempfile.NamedTemporaryFile( - mode="w", suffix=".py", delete=False - ) as tmp_script: - tmp_script.write(SERVER_CODE) - tmp_script_path = tmp_script.name - - try: - process = subprocess.Popen( - [PYTHON_EXE, "-u", tmp_script_path], - stderr=subprocess.PIPE, - stdout=subprocess.PIPE, - text=True, - universal_newlines=True, - ) - - time.sleep(3) - - if process.poll() is None: - try: - with httpx.Client(timeout=5.0) as client: - # The mounted FastMCP app is at root, its internal default path is /mcp - client.get("http://localhost:8080/mcp/") - except httpx.RequestError: - pass - time.sleep(1) - - finally: - if process: - if process.poll() is None: - process.terminate() - try: - _, captured_stderr = process.communicate(timeout=10) - except subprocess.TimeoutExpired: - process.kill() - _, captured_stderr = process.communicate() - - if os.path.exists(tmp_script_path): - os.unlink(tmp_script_path) - - assert captured_stderr is not None, "stderr should have been captured" - normalized_stderr = captured_stderr.replace("\r\n", "\n").replace("\r", "\n") - - assert ( - "FastMCP's StreamableHTTPSessionManager task group was not initialized" - in normalized_stderr - ) - assert "lifespan=mcp_app.lifespan" in normalized_stderr - assert "gofastmcp.com/deployment/asgi" in normalized_stderr - assert "Original error: Task group is not initialized" in normalized_stderr diff --git a/src/fastmcp/utilities/tests.py b/src/fastmcp/utilities/tests.py index 39bd6b9c7..0d64a1292 100644 --- a/src/fastmcp/utilities/tests.py +++ b/src/fastmcp/utilities/tests.py @@ -71,7 +71,7 @@ def _run_server(mcp_server: FastMCP, transport: Literal["sse"], port: int) -> No @contextmanager def run_server_in_process( - server_fn: Callable[[str, int], None], *args + server_fn: Callable[..., None], *args ) -> Generator[str, None, None]: """ Context manager that runs a Starlette app in a separate process and returns the @@ -109,7 +109,11 @@ def run_server_in_process( yield f"http://{host}:{port}" - proc.kill() - proc.join(timeout=2) + proc.terminate() + proc.join(timeout=5) if proc.is_alive(): - raise RuntimeError("Server process failed to terminate") + # If it's still alive, then force kill it + proc.kill() + proc.join(timeout=2) + if proc.is_alive(): + raise RuntimeError("Server process failed to terminate even after kill") diff --git a/tests/contrib/test_bulk_tool_caller.py b/tests/contrib/test_bulk_tool_caller.py index 855341912..b6f86c927 100644 --- a/tests/contrib/test_bulk_tool_caller.py +++ b/tests/contrib/test_bulk_tool_caller.py @@ -85,7 +85,6 @@ ERROR_TOOL_NAME = "error_tool" NO_RETURN_TOOL_NAME = "no_return_tool" -@pytest.mark.asyncio async def test_call_tool_bulk_single_success(bulk_caller_live: BulkToolCaller): """Test single successful call via call_tool_bulk using echo_tool.""" tool_arguments = [{"arg1": "value1"}] @@ -98,7 +97,6 @@ async def test_call_tool_bulk_single_success(bulk_caller_live: BulkToolCaller): assert result == expected_result -@pytest.mark.asyncio async def test_call_tool_bulk_multiple_success(bulk_caller_live: BulkToolCaller): """Test multiple successful calls via call_tool_bulk using echo_tool.""" tool_arguments = [{"arg1": "value1"}, {"arg1": "value2"}] @@ -110,7 +108,6 @@ async def test_call_tool_bulk_multiple_success(bulk_caller_live: BulkToolCaller) assert results == expected_results -@pytest.mark.asyncio async def test_call_tool_bulk_error_stops(bulk_caller_live: BulkToolCaller): """Test call_tool_bulk stops on first error using error_tool.""" tool_arguments = [{"arg1": "error_value"}, {"arg1": "value2"}] @@ -125,7 +122,6 @@ async def test_call_tool_bulk_error_stops(bulk_caller_live: BulkToolCaller): assert result == expected_result -@pytest.mark.asyncio async def test_call_tool_bulk_error_continues(bulk_caller_live: BulkToolCaller): """Test call_tool_bulk continues on error using error_tool and echo_tool.""" tool_arguments = [{"arg1": "error_value"}, {"arg1": "success_value"}] @@ -148,7 +144,6 @@ async def test_call_tool_bulk_error_continues(bulk_caller_live: BulkToolCaller): assert success_result == expected_success_result -@pytest.mark.asyncio async def test_call_tools_bulk_single_success(bulk_caller_live: BulkToolCaller): """Test single successful call via call_tools_bulk using echo_tool.""" tool_calls = [CallToolRequest(tool=ECHO_TOOL_NAME, arguments={"arg1": "value1"})] @@ -161,7 +156,6 @@ async def test_call_tools_bulk_single_success(bulk_caller_live: BulkToolCaller): assert result == expected_result -@pytest.mark.asyncio async def test_call_tools_bulk_multiple_success(bulk_caller_live: BulkToolCaller): """Test multiple successful calls via call_tools_bulk with different tools.""" tool_calls = [ @@ -181,7 +175,6 @@ async def test_call_tools_bulk_multiple_success(bulk_caller_live: BulkToolCaller assert results == expected_results -@pytest.mark.asyncio async def test_call_tools_bulk_error_stops(bulk_caller_live: BulkToolCaller): """Test call_tools_bulk stops on first error using error_tool.""" tool_calls = [ @@ -199,7 +192,6 @@ async def test_call_tools_bulk_error_stops(bulk_caller_live: BulkToolCaller): assert result == expected_result -@pytest.mark.asyncio async def test_call_tools_bulk_error_continues(bulk_caller_live: BulkToolCaller): """Test call_tools_bulk continues on error using error_tool and echo_tool.""" tool_calls = [ diff --git a/tests/server/test_auth_integration.py b/tests/server/test_auth_integration.py index fb0330dcf..f64bd0843 100644 --- a/tests/server/test_auth_integration.py +++ b/tests/server/test_auth_integration.py @@ -341,7 +341,6 @@ async def tokens(test_client, registered_client, auth_code, pkce_challenge, requ class TestAuthEndpoints: - @pytest.mark.anyio async def test_metadata_endpoint(self, test_client: httpx.AsyncClient): """Test the OAuth 2.0 metadata endpoint.""" print("Sending request to metadata endpoint") @@ -370,7 +369,6 @@ class TestAuthEndpoints: ] assert metadata["service_documentation"] == "https://docs.example.com/" - @pytest.mark.anyio async def test_token_validation_error(self, test_client: httpx.AsyncClient): """Test token endpoint error - validation error.""" # Missing required fields @@ -387,7 +385,6 @@ class TestAuthEndpoints: "error_description" in error_response ) # Contains validation error messages - @pytest.mark.anyio async def test_token_invalid_auth_code( self, test_client, registered_client, pkce_challenge ): @@ -414,7 +411,6 @@ class TestAuthEndpoints: "authorization code does not exist" in error_response["error_description"] ) - @pytest.mark.anyio async def test_token_expired_auth_code( self, test_client, @@ -459,7 +455,6 @@ class TestAuthEndpoints: "authorization code has expired" in error_response["error_description"] ) - @pytest.mark.anyio @pytest.mark.parametrize( "registered_client", [ @@ -494,7 +489,6 @@ class TestAuthEndpoints: assert error_response["error"] == "invalid_request" assert "redirect_uri did not match" in error_response["error_description"] - @pytest.mark.anyio async def test_token_code_verifier_mismatch( self, test_client, registered_client, auth_code ): @@ -517,7 +511,6 @@ class TestAuthEndpoints: assert error_response["error"] == "invalid_grant" assert "incorrect code_verifier" in error_response["error_description"] - @pytest.mark.anyio async def test_token_invalid_refresh_token(self, test_client, registered_client): """Test token endpoint error - refresh token does not exist.""" # Try to use a non-existent refresh token @@ -535,7 +528,6 @@ class TestAuthEndpoints: assert error_response["error"] == "invalid_grant" assert "refresh token does not exist" in error_response["error_description"] - @pytest.mark.anyio async def test_token_expired_refresh_token( self, test_client, @@ -586,7 +578,6 @@ class TestAuthEndpoints: assert error_response["error"] == "invalid_grant" assert "refresh token has expired" in error_response["error_description"] - @pytest.mark.anyio async def test_token_invalid_scope( self, test_client, registered_client, auth_code, pkce_challenge ): @@ -624,7 +615,6 @@ class TestAuthEndpoints: assert error_response["error"] == "invalid_scope" assert "cannot request scope" in error_response["error_description"] - @pytest.mark.anyio async def test_client_registration( self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider ): @@ -652,7 +642,6 @@ class TestAuthEndpoints: # client_info["client_id"] # ) is not None - @pytest.mark.anyio async def test_client_registration_missing_required_fields( self, test_client: httpx.AsyncClient ): @@ -673,7 +662,6 @@ class TestAuthEndpoints: assert error_data["error"] == "invalid_client_metadata" assert error_data["error_description"] == "redirect_uris: Field required" - @pytest.mark.anyio async def test_client_registration_invalid_uri( self, test_client: httpx.AsyncClient ): @@ -696,7 +684,6 @@ class TestAuthEndpoints: "redirect_uris.0: Input should be a valid URL, relative URL without a base" ) - @pytest.mark.anyio async def test_client_registration_empty_redirect_uris( self, test_client: httpx.AsyncClient ): @@ -719,7 +706,6 @@ class TestAuthEndpoints: == "redirect_uris: List should have at least 1 item after validation, not 0" ) - @pytest.mark.anyio async def test_authorize_form_post( self, test_client: httpx.AsyncClient, @@ -763,7 +749,6 @@ class TestAuthEndpoints: assert "code" in query_params assert query_params["state"][0] == "test_form_state" - @pytest.mark.anyio async def test_authorization_get( self, test_client: httpx.AsyncClient, @@ -878,7 +863,6 @@ class TestAuthEndpoints: is None ) - @pytest.mark.anyio async def test_revoke_invalid_token(self, test_client, registered_client): """Test revoking an invalid token.""" response = await test_client.post( @@ -892,7 +876,6 @@ class TestAuthEndpoints: # per RFC, this should return 200 even if the token is invalid assert response.status_code == 200 - @pytest.mark.anyio async def test_revoke_with_malformed_token(self, test_client, registered_client): response = await test_client.post( "/revoke", @@ -908,7 +891,6 @@ class TestAuthEndpoints: assert error_response["error"] == "invalid_request" assert "token_type_hint" in error_response["error_description"] - @pytest.mark.anyio async def test_client_registration_disallowed_scopes( self, test_client: httpx.AsyncClient ): @@ -930,7 +912,6 @@ class TestAuthEndpoints: assert "scope" in error_data["error_description"] assert "admin" in error_data["error_description"] - @pytest.mark.anyio async def test_client_registration_default_scopes( self, test_client: httpx.AsyncClient, mock_oauth_provider: MockOAuthProvider ): @@ -959,7 +940,6 @@ class TestAuthEndpoints: # Check that default scopes were applied assert registered_client.scope == "read write" - @pytest.mark.anyio async def test_client_registration_invalid_grant_type( self, test_client: httpx.AsyncClient ): @@ -986,7 +966,6 @@ class TestAuthEndpoints: class TestAuthorizeEndpointErrors: """Test error handling in the OAuth authorization endpoint.""" - @pytest.mark.anyio async def test_authorize_missing_client_id( self, test_client: httpx.AsyncClient, pkce_challenge ): @@ -1012,7 +991,6 @@ class TestAuthorizeEndpointErrors: # The response should include an error message about missing client_id assert "client_id" in response.text.lower() - @pytest.mark.anyio async def test_authorize_invalid_client_id( self, test_client: httpx.AsyncClient, pkce_challenge ): @@ -1038,7 +1016,6 @@ class TestAuthorizeEndpointErrors: # The response should include an error message about invalid client_id assert "client" in response.text.lower() - @pytest.mark.anyio async def test_authorize_missing_redirect_uri( self, test_client: httpx.AsyncClient, registered_client, pkce_challenge ): @@ -1064,7 +1041,6 @@ class TestAuthorizeEndpointErrors: redirect_url = response.headers["location"] assert redirect_url.startswith("https://client.example.com/callback") - @pytest.mark.anyio async def test_authorize_invalid_redirect_uri( self, test_client: httpx.AsyncClient, registered_client, pkce_challenge ): @@ -1092,7 +1068,6 @@ class TestAuthorizeEndpointErrors: # The response should include an error message about redirect_uri mismatch assert "redirect" in response.text.lower() - @pytest.mark.anyio @pytest.mark.parametrize( "registered_client", [ @@ -1130,7 +1105,6 @@ class TestAuthorizeEndpointErrors: # The response should include an error message about missing redirect_uri assert "redirect_uri" in response.text.lower() - @pytest.mark.anyio async def test_authorize_unsupported_response_type( self, test_client: httpx.AsyncClient, registered_client, pkce_challenge ): @@ -1164,7 +1138,6 @@ class TestAuthorizeEndpointErrors: assert "state" in query_params assert query_params["state"][0] == "test_state" - @pytest.mark.anyio async def test_authorize_missing_response_type( self, test_client: httpx.AsyncClient, registered_client, pkce_challenge ): @@ -1197,7 +1170,6 @@ class TestAuthorizeEndpointErrors: assert "state" in query_params assert query_params["state"][0] == "test_state" - @pytest.mark.anyio async def test_authorize_missing_pkce_challenge( self, test_client: httpx.AsyncClient, registered_client ): @@ -1228,7 +1200,6 @@ class TestAuthorizeEndpointErrors: assert "state" in query_params assert query_params["state"][0] == "test_state" - @pytest.mark.anyio async def test_authorize_invalid_scope( self, test_client: httpx.AsyncClient, registered_client, pkce_challenge ): diff --git a/tests/server/test_http_middleware.py b/tests/server/test_http_middleware.py index e48dc127b..7a1ab22f4 100644 --- a/tests/server/test_http_middleware.py +++ b/tests/server/test_http_middleware.py @@ -4,7 +4,6 @@ from collections.abc import Callable from typing import Any import httpx -import pytest from httpx import ASGITransport from starlette.middleware import Middleware from starlette.middleware.base import BaseHTTPMiddleware @@ -51,7 +50,6 @@ async def endpoint_handler(request: Request): return JSONResponse({"message": "Hello, world!"}) -@pytest.mark.asyncio async def test_sse_app_with_custom_middleware(): """Test that custom middleware works with SSE app.""" server = FastMCP(name="TestServer") @@ -82,7 +80,6 @@ async def test_sse_app_with_custom_middleware(): assert response.headers["X-Custom-Header"] == "test-value" -@pytest.mark.asyncio async def test_streamable_http_app_with_custom_middleware(): """Test that custom middleware works with StreamableHTTP app.""" server = FastMCP(name="TestServer") @@ -113,7 +110,6 @@ async def test_streamable_http_app_with_custom_middleware(): assert response.headers["X-Custom-Header"] == "test-value" -@pytest.mark.asyncio async def test_create_sse_app_with_custom_middleware(): """Test that custom middleware works with create_sse_app function.""" server = FastMCP(name="TestServer") @@ -149,7 +145,6 @@ async def test_create_sse_app_with_custom_middleware(): assert data["state"]["modified_by"] == "middleware" -@pytest.mark.asyncio async def test_create_streamable_http_app_with_custom_middleware(): """Test that custom middleware works with create_streamable_http_app function.""" server = FastMCP(name="TestServer") @@ -184,7 +179,6 @@ async def test_create_streamable_http_app_with_custom_middleware(): assert data["state"]["modified_by"] == "middleware" -@pytest.mark.asyncio async def test_multiple_middleware_ordering(): """Test that multiple middleware are applied in the correct order.""" server = FastMCP(name="TestServer") diff --git a/tests/server/test_lifespan.py b/tests/server/test_lifespan.py index 8b11ca825..ad041bbf9 100644 --- a/tests/server/test_lifespan.py +++ b/tests/server/test_lifespan.py @@ -1,10 +1,15 @@ """Tests for lifespan functionality in both low-level and FastMCP servers.""" +import os +import sys +import traceback from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from pathlib import Path import anyio -import pytest +import httpx +import uvicorn from mcp.server.lowlevel.server import NotificationOptions, Server from mcp.server.models import InitializationOptions from mcp.shared.message import SessionMessage @@ -17,11 +22,13 @@ from mcp.types import ( JSONRPCRequest, ) from pydantic import TypeAdapter +from starlette.applications import Starlette +from starlette.routing import Mount from fastmcp import Context, FastMCP +from fastmcp.utilities.tests import run_server_in_process -@pytest.mark.anyio async def test_lowlevel_server_lifespan(): """Test that lifespan works in low-level server.""" @@ -132,7 +139,6 @@ async def test_lowlevel_server_lifespan(): tg.cancel_scope.cancel() -@pytest.mark.anyio async def test_fastmcp_server_lifespan(): """Test that lifespan works in FastMCP server.""" @@ -234,3 +240,157 @@ async def test_fastmcp_server_lifespan(): # Cancel server task tg.cancel_scope.cancel() + + +def run_server_with_incorrect_lifespan_setup( + host: str, port: int, server_log_file_path: str +) -> None: + os.makedirs(os.path.dirname(server_log_file_path), exist_ok=True) + + CUSTOM_LOGGING_CONFIG = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "default": { + "()": "uvicorn.logging.DefaultFormatter", + "fmt": "%(levelprefix)s %(asctime)s [%(name)s] %(message)s", + "datefmt": "%Y-%m-%d %H:%M:%S", + "use_colors": False, + }, + "access": { + "()": "uvicorn.logging.AccessFormatter", + "fmt": '%(levelprefix)s %(asctime)s [%(name)s] %(client_addr)s - "%(request_line)s" %(status_code)s', + "datefmt": "%Y-%m-%d %H:%M:%S", + "use_colors": False, + }, + }, + "handlers": { + "file_default": { + "formatter": "default", + "class": "logging.FileHandler", + "filename": server_log_file_path, + "mode": "w", + }, + "file_access": { + "formatter": "access", + "class": "logging.FileHandler", + "filename": server_log_file_path, + "mode": "a", + }, + }, + "loggers": { + "uvicorn": { # Catches uvicorn root logs + "handlers": ["file_default"], + "level": "DEBUG", + "propagate": False, + }, + "uvicorn.error": { + "handlers": ["file_default"], + "level": "DEBUG", + "propagate": False, + }, + "uvicorn.access": { + "handlers": ["file_access"], + "level": "INFO", + "propagate": False, + }, + }, + "root": { + "handlers": ["file_default"], + "level": "DEBUG", + }, + } + + try: + mcp = FastMCP() + + @mcp.tool("ping_tool", "A simple ping tool for the test server") + def ping_tool() -> str: + return "pong" + + mcp_asgi_app = mcp.http_app(transport="streamable-http") + + parent_app = Starlette( + routes=[Mount("/mounted_mcp", app=mcp_asgi_app)], + ) + + uvicorn.run( + parent_app, + host=host, + port=port, + log_config=CUSTOM_LOGGING_CONFIG, + log_level=None, + ) + sys.exit(0) + except Exception as e_outer: + with open(server_log_file_path, "a") as f_fallback: + f_fallback.write( + "--- FALLBACK EXCEPTION IN SERVER RUNNER (PRE-UVICORN) ---\n" + ) + f_fallback.write(f"{type(e_outer).__name__}: {e_outer}\n") + f_fallback.write(traceback.format_exc()) + sys.exit(1) + + +async def test_missing_lifespan_logs_informative_error(tmp_path: Path): + server_log_file = tmp_path / "server.log" + + with run_server_in_process( + run_server_with_incorrect_lifespan_setup, str(server_log_file) + ) as server_url: + full_mcp_path = server_url + "/mounted_mcp/mcp/" + + client_triggered_error = False + response_status = -1 + response_body = "" + try: + async with httpx.AsyncClient(timeout=10) as client: + response = await client.post( + full_mcp_path, + json={"id": 1, "method": "list_tools", "jsonrpc": "2.0"}, + ) + response_status = response.status_code + response_body = response.text + if response.status_code == 500: + client_triggered_error = True + else: + print( + f"Client received unexpected status code: {response.status_code} " + f"Response: {response_body[:500]}" + ) + except httpx.RequestError as e: + print(f"Client request failed with RequestError: {e}") + client_triggered_error = True + + assert client_triggered_error, ( + f"Client request did not result in a 500 error or a request error. " + f"Status: {response_status}, Body: {response_body[:500]}" + ) + + assert server_log_file.exists(), ( + f"Server log file was not created at {server_log_file}" + ) + log_content = server_log_file.read_text() + + print(f"--- Captured Server Log Content ({server_log_file}) ---") + print(log_content) + print("--- End Server Log Content ---") + + # Core assertions for the enhanced error message + assert ( + "FastMCP's StreamableHTTPSessionManager task group was not initialized" + in log_content + ) + assert "lifespan=mcp_app.lifespan" in log_content + assert "gofastmcp.com/deployment/asgi" in log_content + assert "Original error: Task group is not initialized" in log_content + + # Check for Uvicorn's own error logging wrapper for the request + assert "ERROR" in log_content # General check for ERROR level logs + assert "Exception in ASGI application" in log_content + + # Sanity checks for server operation and logging setup + assert "Uvicorn running on" in log_content + assert ( + "--- FALLBACK EXCEPTION IN SERVER RUNNER (PRE-UVICORN) ---" not in log_content + ) diff --git a/tests/server/test_openapi_path_parameters.py b/tests/server/test_openapi_path_parameters.py index e8b6121e5..9940594a8 100644 --- a/tests/server/test_openapi_path_parameters.py +++ b/tests/server/test_openapi_path_parameters.py @@ -87,7 +87,6 @@ async def test_fastmcp_from_openapi(array_path_spec, mock_client): assert "test-operation" in tool_names -@pytest.mark.asyncio async def test_array_path_parameter_handling(mock_client): """Test how array path parameters are handled.""" # Create a simple route with array path parameter @@ -158,7 +157,6 @@ async def test_array_path_parameter_handling(mock_client): ) -@pytest.mark.asyncio async def test_integration_array_path_parameter(array_path_spec, mock_client): """Integration test for array path parameters.""" # Create FastMCP from the spec @@ -192,7 +190,6 @@ async def test_integration_array_path_parameter(array_path_spec, mock_client): ) -@pytest.mark.asyncio async def test_complex_nested_array_path_parameter(mock_client): """Test handling of complex nested array path parameters.""" # Create a route with a path parameter that contains nested objects in an array @@ -262,7 +259,6 @@ async def test_complex_nested_array_path_parameter(mock_client): assert "{" not in called_url, "The URL should not contain Python object syntax" -@pytest.mark.asyncio async def test_array_query_param_with_fastapi(): """Test array query parameters using FastAPI and FastMCP.from_fastapi integration.""" # Create a FastAPI app with a route that has an array query parameter @@ -323,7 +319,6 @@ async def test_array_query_param_with_fastapi(): assert result_data == {"selected": ["monday", "tuesday"]} -@pytest.mark.asyncio async def test_array_query_parameter_format(mock_client): """Test that array query parameters are formatted as comma-separated values when explode=False.""" # Create a route with array query parameter @@ -394,7 +389,6 @@ async def test_array_query_parameter_format(mock_client): ) -@pytest.mark.asyncio async def test_array_query_parameter_exploded_format(mock_client): """Test that array query parameters are formatted as separate parameters when explode=True.""" # Create a route with array query parameter with explode=True (default) diff --git a/tests/test_deprecated.py b/tests/test_deprecated.py index ecb63bc8e..65f6fc71d 100644 --- a/tests/test_deprecated.py +++ b/tests/test_deprecated.py @@ -40,7 +40,6 @@ def test_streamable_http_app_deprecation_warning(): assert isinstance(app, Starlette) -@pytest.mark.asyncio async def test_run_sse_async_deprecation_warning(): """Test that run_sse_async raises a deprecation warning.""" server = FastMCP("TestServer") @@ -58,7 +57,6 @@ async def test_run_sse_async_deprecation_warning(): assert call_kwargs.get("transport") == "sse" -@pytest.mark.asyncio async def test_run_streamable_http_async_deprecation_warning(): """Test that run_streamable_http_async raises a deprecation warning.""" server = FastMCP("TestServer") diff --git a/tests/test_examples.py b/tests/test_examples.py index 1f68390d8..fcee6c521 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,6 +1,5 @@ """Tests for example servers""" -import pytest from mcp.types import ( PromptMessage, TextContent, @@ -11,7 +10,6 @@ from pydantic import AnyUrl from fastmcp import Client -@pytest.mark.anyio async def test_simple_echo(): """Test the simple echo server""" from examples.simple_echo import mcp @@ -23,7 +21,6 @@ async def test_simple_echo(): assert result[0].text == "hello" -@pytest.mark.anyio async def test_complex_inputs(): """Test the complex inputs server""" from examples.complex_inputs import mcp @@ -38,7 +35,6 @@ async def test_complex_inputs(): assert result[0].text == '[\n "bob",\n "alice",\n "charlie"\n]' -@pytest.mark.anyio async def test_desktop(monkeypatch): """Test the desktop server""" from examples.desktop import mcp @@ -58,7 +54,6 @@ async def test_desktop(monkeypatch): assert result[0].text == "Hello, rooter12!" -@pytest.mark.anyio async def test_echo(): """Test the echo server""" from examples.echo import mcp From 2b7712df86e007197a18f05ff302a6b425b77088 Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Tue, 20 May 2025 13:53:40 -0500 Subject: [PATCH 034/114] take suggestion --- src/fastmcp/server/http.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/fastmcp/server/http.py b/src/fastmcp/server/http.py index 18c78be76..2a9cced00 100644 --- a/src/fastmcp/server/http.py +++ b/src/fastmcp/server/http.py @@ -309,7 +309,10 @@ def create_streamable_http_app( try: await session_manager.handle_request(scope, receive, send) except RuntimeError as e: - if "Task group is not initialized" in str(e): + if str(e) == "Task group is not initialized. Make sure to use run().": + logger.error( + f"Original RuntimeError from mcp library: {e}", exc_info=True + ) new_error_message = ( "FastMCP's StreamableHTTPSessionManager task group was not initialized. " "This commonly occurs when the FastMCP application's lifespan is not " From f5cddc321902976aeb8f126e6f67575baa38f2fe Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 20 May 2025 17:43:32 -0400 Subject: [PATCH 035/114] Formalize MCP Config --- src/fastmcp/client/transports.py | 36 +++++--- src/fastmcp/server/proxy.py | 8 ++ .../{client => utilities}/mcp_config.py | 58 +++++++----- tests/utilities/test_mcp_config.py | 92 +++++++++++++++++++ 4 files changed, 162 insertions(+), 32 deletions(-) rename src/fastmcp/{client => utilities}/mcp_config.py (54%) create mode 100644 tests/utilities/test_mcp_config.py diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index b30a6e0d6..dcd3450c8 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -6,7 +6,7 @@ import shutil import sys from collections.abc import AsyncIterator from pathlib import Path -from typing import TYPE_CHECKING, Any, TypedDict, cast +from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast from urllib.parse import urlparse from mcp import ClientSession, StdioServerParameters @@ -24,12 +24,12 @@ from mcp.shared.memory import create_connected_server_and_client_session from pydantic import AnyUrl from typing_extensions import Unpack -from fastmcp.client.mcp_config import MCPConfig from fastmcp.server import FastMCP as FastMCPServer from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.mcp_config import MCPConfig if TYPE_CHECKING: - from fastmcp.client.mcp_config import MCPConfig + from fastmcp.utilities.mcp_config import MCPConfig logger = get_logger(__name__) @@ -491,7 +491,7 @@ def infer_transport( For HTTP URLs, they are assumed to be Streamable HTTP URLs unless they end in `/sse`. """ - from fastmcp.client.mcp_config import MCPConfig + from fastmcp.utilities.mcp_config import MCPConfig # the transport is already a ClientTransport if isinstance(transport, ClientTransport): @@ -512,13 +512,8 @@ def infer_transport( # the transport is an http(s) URL elif isinstance(transport, AnyUrl | str) and str(transport).startswith("http"): - transport_str = str(transport) - # Parse out just the path portion to check for /sse - parsed_url = urlparse(transport_str) - path = parsed_url.path - - # Check if path contains /sse/ or ends with /sse - if "/sse/" in path or path.rstrip("/").endswith("/sse"): + inferred_transport_type = infer_transport_type_from_url(transport) + if inferred_transport_type == "sse": inferred_transport = SSETransport(url=transport) else: inferred_transport = StreamableHttpTransport(url=transport) @@ -542,3 +537,22 @@ def infer_transport( logger.debug(f"Inferred transport: {inferred_transport}") return inferred_transport + + +def infer_transport_type_from_url( + url: str | AnyUrl, +) -> Literal["streamable-http", "sse"]: + """ + Infer the appropriate transport type from the given URL. + """ + url = str(url) + if not url.startswith("http"): + raise ValueError(f"Invalid URL: {url}") + + parsed_url = urlparse(url) + path = parsed_url.path + + if "/sse/" in path or path.rstrip("/").endswith("/sse"): + return "sse" + else: + return "streamable-http" diff --git a/src/fastmcp/server/proxy.py b/src/fastmcp/server/proxy.py index 8f7123bab..9fcdcdb26 100644 --- a/src/fastmcp/server/proxy.py +++ b/src/fastmcp/server/proxy.py @@ -25,6 +25,7 @@ from fastmcp.server.context import Context from fastmcp.server.server import FastMCP from fastmcp.tools.tool import Tool from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.mcp_config import MCPConfig if TYPE_CHECKING: from fastmcp.server import Context @@ -177,6 +178,13 @@ class FastMCPProxy(FastMCP): super().__init__(**kwargs) self.client = client + @classmethod + async def from_mcp_config(cls, config: MCPConfig | dict) -> FastMCPProxy: + if isinstance(config, dict): + config = MCPConfig.from_dict(config) + clients = config.to_clients() + return cls(client=clients[list(clients.keys())[0]]) + async def get_tools(self) -> dict[str, Tool]: tools = await super().get_tools() diff --git a/src/fastmcp/client/mcp_config.py b/src/fastmcp/utilities/mcp_config.py similarity index 54% rename from src/fastmcp/client/mcp_config.py rename to src/fastmcp/utilities/mcp_config.py index d89eaf2f7..7330bd65f 100644 --- a/src/fastmcp/client/mcp_config.py +++ b/src/fastmcp/utilities/mcp_config.py @@ -1,9 +1,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Annotated, Any, Literal, TypeAlias +from typing import TYPE_CHECKING, Any, Literal +from urllib.parse import urlparse -from pydantic import Field -from pydantic.dataclasses import dataclass +from pydantic import AnyUrl, BaseModel, Field if TYPE_CHECKING: from fastmcp.client.client import Client @@ -14,10 +14,28 @@ if TYPE_CHECKING: ) -@dataclass -class LocalMCPServer: +def infer_transport_type_from_url( + url: str | AnyUrl, +) -> Literal["streamable-http", "sse"]: + """ + Infer the appropriate transport type from the given URL. + """ + url = str(url) + if not url.startswith("http"): + raise ValueError(f"Invalid URL: {url}") + + parsed_url = urlparse(url) + path = parsed_url.path + + if "/sse/" in path or path.rstrip("/").endswith("/sse"): + return "sse" + else: + return "streamable-http" + + +class LocalMCPServer(BaseModel): command: str - args: list[str] + args: list[str] = Field(default_factory=list) env: dict[str, Any] = Field(default_factory=dict) cwd: str | None = None @@ -32,38 +50,36 @@ class LocalMCPServer: ) -@dataclass -class RemoteMCPServer: +class RemoteMCPServer(BaseModel): url: str - transport: Literal["http", "sse"] | None = None + transport: Literal["streamable-http", "sse", "http"] | None = None headers: dict[str, str] = Field(default_factory=dict) def to_transport(self) -> StreamableHttpTransport | SSETransport: from fastmcp.client.transports import SSETransport, StreamableHttpTransport - if self.transport in {"http", None}: - return StreamableHttpTransport(self.url, headers=self.headers) + if self.transport is None: + transport = infer_transport_type_from_url(self.url) else: + transport = self.transport + + if transport == "sse": return SSETransport(self.url, headers=self.headers) + else: + return StreamableHttpTransport(self.url, headers=self.headers) -MCPServer: TypeAlias = LocalMCPServer | RemoteMCPServer - - -@dataclass -class MCPConfig: - mcp_servers: Annotated[dict[str, MCPServer], Field(alias="mcpServers")] +class MCPConfig(BaseModel): + mcpServers: dict[str, LocalMCPServer | RemoteMCPServer] @classmethod def from_dict(cls, config: dict[str, Any]) -> MCPConfig: - return cls(mcp_servers=config.get("mcpServers", config)) + return cls(mcpServers=config.get("mcpServers", config)) def to_transports( self, ) -> dict[str, StdioTransport | StreamableHttpTransport | SSETransport]: - return { - name: server.to_transport() for name, server in self.mcp_servers.items() - } + return {name: server.to_transport() for name, server in self.mcpServers.items()} def to_clients(self) -> dict[str, Client]: from fastmcp.client.client import Client diff --git a/tests/utilities/test_mcp_config.py b/tests/utilities/test_mcp_config.py new file mode 100644 index 000000000..011a0574d --- /dev/null +++ b/tests/utilities/test_mcp_config.py @@ -0,0 +1,92 @@ +from fastmcp.client.transports import ( + SSETransport, + StdioTransport, + StreamableHttpTransport, +) +from fastmcp.utilities.mcp_config import LocalMCPServer, MCPConfig, RemoteMCPServer + + +def test_parse_single_stdio_config(): + config = { + "mcpServers": { + "test_server": { + "command": "echo", + "args": ["hello"], + } + } + } + mcp_config = MCPConfig.from_dict(config) + transport = mcp_config.mcpServers["test_server"].to_transport() + assert isinstance(transport, StdioTransport) + assert transport.command == "echo" + assert transport.args == ["hello"] + + +def test_parse_single_remote_config(): + config = { + "mcpServers": { + "test_server": { + "url": "http://localhost:8000", + } + } + } + mcp_config = MCPConfig.from_dict(config) + transport = mcp_config.mcpServers["test_server"].to_transport() + assert isinstance(transport, StreamableHttpTransport) + assert transport.url == "http://localhost:8000" + + +def test_parse_remote_config_with_transport(): + config = { + "mcpServers": { + "test_server": { + "url": "http://localhost:8000", + "transport": "sse", + } + } + } + mcp_config = MCPConfig.from_dict(config) + transport = mcp_config.mcpServers["test_server"].to_transport() + assert isinstance(transport, SSETransport) + assert transport.url == "http://localhost:8000" + + +def test_parse_remote_config_with_url_inference(): + config = { + "mcpServers": { + "test_server": { + "url": "http://localhost:8000/sse", + } + } + } + mcp_config = MCPConfig.from_dict(config) + transport = mcp_config.mcpServers["test_server"].to_transport() + assert isinstance(transport, SSETransport) + assert transport.url == "http://localhost:8000/sse" + + +def test_parse_multiple_servers(): + config = { + "mcpServers": { + "test_server": { + "url": "http://localhost:8000/sse", + }, + "test_server_2": { + "command": "echo", + "args": ["hello"], + "env": {"TEST": "test"}, + }, + } + } + mcp_config = MCPConfig.from_dict(config) + assert len(mcp_config.mcpServers) == 2 + assert isinstance(mcp_config.mcpServers["test_server"], RemoteMCPServer) + assert isinstance(mcp_config.mcpServers["test_server"].to_transport(), SSETransport) + + assert isinstance(mcp_config.mcpServers["test_server_2"], LocalMCPServer) + assert isinstance( + mcp_config.mcpServers["test_server_2"].to_transport(), StdioTransport + ) + assert mcp_config.mcpServers["test_server_2"].command == "echo" + assert mcp_config.mcpServers["test_server_2"].args == ["hello"] + assert mcp_config.mcpServers["test_server_2"].env == {"TEST": "test"} From 02cb6f8291c5f0365cac5ad6246b62234e9a4cbc Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 20 May 2025 17:45:13 -0400 Subject: [PATCH 036/114] Add config inference test --- tests/client/test_client.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/client/test_client.py b/tests/client/test_client.py index efaa7f516..e63f80179 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -10,6 +10,7 @@ from fastmcp.client import Client from fastmcp.client.transports import ( FastMCPTransport, SSETransport, + StdioTransport, StreamableHttpTransport, infer_transport, ) @@ -640,3 +641,31 @@ class TestInferTransport: def test_url_returns_streamable_http_transport(self, url): """Test that URLs without /sse/ pattern return StreamableHttpTransport.""" assert isinstance(infer_transport(url), StreamableHttpTransport) + + def test_infer_remote_transport_from_config(self): + config = { + "mcpServers": { + "test_server": { + "url": "http://localhost:8000/sse", + "headers": {"Authorization": "Bearer 123"}, + }, + } + } + transport = infer_transport(config) + assert isinstance(transport, SSETransport) + assert transport.url == "http://localhost:8000/sse" + assert transport.headers == {"Authorization": "Bearer 123"} + + def test_infer_local_transport_from_config(self): + config = { + "mcpServers": { + "test_server": { + "command": "echo", + "args": ["hello"], + }, + } + } + transport = infer_transport(config) + assert isinstance(transport, StdioTransport) + assert transport.command == "echo" + assert transport.args == ["hello"] From 34b2d49ab6f298aa96c8e4402a95540561f02e41 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 20 May 2025 17:46:27 -0400 Subject: [PATCH 037/114] Update transports.py --- src/fastmcp/client/transports.py | 24 ++---------------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index dcd3450c8..9bf2cac07 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -6,8 +6,7 @@ import shutil import sys from collections.abc import AsyncIterator from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast -from urllib.parse import urlparse +from typing import TYPE_CHECKING, Any, TypedDict, cast from mcp import ClientSession, StdioServerParameters from mcp.client.session import ( @@ -26,7 +25,7 @@ from typing_extensions import Unpack from fastmcp.server import FastMCP as FastMCPServer from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.mcp_config import MCPConfig +from fastmcp.utilities.mcp_config import MCPConfig, infer_transport_type_from_url if TYPE_CHECKING: from fastmcp.utilities.mcp_config import MCPConfig @@ -537,22 +536,3 @@ def infer_transport( logger.debug(f"Inferred transport: {inferred_transport}") return inferred_transport - - -def infer_transport_type_from_url( - url: str | AnyUrl, -) -> Literal["streamable-http", "sse"]: - """ - Infer the appropriate transport type from the given URL. - """ - url = str(url) - if not url.startswith("http"): - raise ValueError(f"Invalid URL: {url}") - - parsed_url = urlparse(url) - path = parsed_url.path - - if "/sse/" in path or path.rstrip("/").endswith("/sse"): - return "sse" - else: - return "streamable-http" From be95110596435a53ff2059c9370cb7e8726eaf39 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 20 May 2025 18:39:32 -0400 Subject: [PATCH 038/114] Avoid hanging on initialize --- src/fastmcp/client/client.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index dcb4ff013..139ce0333 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -1,8 +1,10 @@ +import asyncio import datetime from contextlib import AsyncExitStack, asynccontextmanager from pathlib import Path from typing import Any, cast +import anyio import mcp.types from exceptiongroup import catch from mcp import ClientSession @@ -161,7 +163,11 @@ class Client: self._initialize_result = await self._session.initialize() try: + with anyio.fail_after(1): + self._initialize_result = await self._session.initialize() yield + except asyncio.TimeoutError: + raise RuntimeError("Failed to initialize server session") finally: self._exit_stack = None self._session = None From ec76d180bcd6de610fc1e30e83d5d4a0f006a527 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 20 May 2025 19:05:01 -0400 Subject: [PATCH 039/114] Remove extra initialize --- src/fastmcp/client/client.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 139ce0333..bafdf3385 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -160,8 +160,6 @@ class Client: ) as session: self._session = session # Initialize the session - self._initialize_result = await self._session.initialize() - try: with anyio.fail_after(1): self._initialize_result = await self._session.initialize() From f084f3e3db88d00d00428e68ec85548b9182301d Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 20 May 2025 19:05:38 -0400 Subject: [PATCH 040/114] Trap timeouterror --- src/fastmcp/client/client.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index bafdf3385..98c3298be 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -1,4 +1,3 @@ -import asyncio import datetime from contextlib import AsyncExitStack, asynccontextmanager from pathlib import Path @@ -164,7 +163,7 @@ class Client: with anyio.fail_after(1): self._initialize_result = await self._session.initialize() yield - except asyncio.TimeoutError: + except TimeoutError: raise RuntimeError("Failed to initialize server session") finally: self._exit_stack = None From cec40ddfeade9423ea9d1f668dac3f56f3edba4f Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 20 May 2025 20:30:33 -0400 Subject: [PATCH 041/114] Remove customizable separators; improve resource separator --- docs/servers/composition.mdx | 44 +--- src/fastmcp/server/server.py | 290 +++++++++++++++------- tests/deprecated/__init__.py | 0 tests/{ => deprecated}/test_deprecated.py | 80 ++++++ tests/deprecated/test_mount_separators.py | 85 +++++++ tests/server/test_import_server.py | 42 ++-- tests/server/test_mount.py | 75 +++--- tests/server/test_openapi.py | 24 +- tests/server/test_server.py | 290 ++++++++++++++++++++++ 9 files changed, 720 insertions(+), 210 deletions(-) create mode 100644 tests/deprecated/__init__.py rename tests/{ => deprecated}/test_deprecated.py (56%) create mode 100644 tests/deprecated/test_mount_separators.py diff --git a/docs/servers/composition.mdx b/docs/servers/composition.mdx index 7f14f0609..b1308fa55 100644 --- a/docs/servers/composition.mdx +++ b/docs/servers/composition.mdx @@ -65,7 +65,7 @@ async def setup(): # Result: main_mcp now contains prefixed components: # - Tool: "weather_get_forecast" -# - Resource: "weather+data://cities/supported" +# - Resource: "data://weather/cities/supported" if __name__ == "__main__": asyncio.run(setup()) @@ -78,11 +78,11 @@ When you call `await main_mcp.import_server(prefix, subserver)`: 1. **Tools**: All tools from `subserver` are added to `main_mcp` with names prefixed using `{prefix}_`. - `subserver.tool(name="my_tool")` becomes `main_mcp.tool(name="{prefix}_my_tool")`. -2. **Resources**: All resources are added with URIs prefixed using `{prefix}+`. - - `subserver.resource(uri="data://info")` becomes `main_mcp.resource(uri="{prefix}+data://info")`. +2. **Resources**: All resources are added with URIs prefixed in the format `protocol://{prefix}/path`. + - `subserver.resource(uri="data://info")` becomes `main_mcp.resource(uri="data://{prefix}/info")`. 3. **Resource Templates**: Templates are prefixed similarly to resources. - - `subserver.resource(uri="data://{id}")` becomes `main_mcp.resource(uri="{prefix}+data://{id}")`. -4. **Prompts**: All prompts are added with names prefixed like tools. + - `subserver.resource(uri="data://{id}")` becomes `main_mcp.resource(uri="data://{prefix}/{id}")`. +4. **Prompts**: All prompts are added with names prefixed using `{prefix}_`. - `subserver.prompt(name="my_prompt")` becomes `main_mcp.prompt(name="{prefix}_my_prompt")`. Note that `import_server` performs a **one-time copy** of components. Changes made to the `subserver` *after* importing **will not** be reflected in `main_mcp`. The `subserver`'s `lifespan` context is also **not** executed by the main server. @@ -177,36 +177,6 @@ remote_proxy = FastMCP.as_proxy(Client("http://example.com/mcp")) main_server.mount("remote", remote_proxy) ``` -## Customizing Separators - -Both `import_server()` and `mount()` allow you to customize the separators used for prefixing components. The defaults are `_` for tools and prompts, and `+` for resources. - - - -```python import_server -await main_mcp.import_server( - 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" -) -``` - -```python mount -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" -) -``` - -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. - - - -To "cleanly" import or mount a server, set the prefix and all separators to `""` (empty string). This is generally unecessary but could save a couple tokens at the risk of a name collision! - +Some MCP clients (like Claude Desktop) might have restrictions on characters allowed in tool names. FastMCP uses standard naming conventions: tools and prompts are prefixed with `{prefix}_` (e.g., "weather_forecast"), and resources use the format `protocol://{prefix}/path` (e.g., "data://weather/forecast"). + \ No newline at end of file diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 1edbcac4d..ff589bbb4 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -3,6 +3,7 @@ from __future__ import annotations import datetime +import re import warnings from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import ( @@ -16,7 +17,6 @@ from typing import TYPE_CHECKING, Any, Generic, Literal import anyio import httpx -import pydantic import uvicorn from mcp.server.auth.provider import OAuthAuthorizationServerProvider from mcp.server.lowlevel.helper_types import ReadResourceContents @@ -935,10 +935,11 @@ class FastMCP(Generic[LifespanResultT]): self, prefix: str, server: FastMCP[LifespanResultT], + as_proxy: bool | None = None, + *, tool_separator: str | None = None, resource_separator: str | None = None, prompt_separator: str | None = None, - as_proxy: bool | None = None, ) -> None: """Mount another FastMCP server on this server with the given prefix. @@ -949,15 +950,15 @@ class FastMCP(Generic[LifespanResultT]): through the parent. When a server is mounted: - - Tools from the mounted server are accessible with prefixed names using the tool_separator. + - Tools from the mounted server are accessible with prefixed names. Example: If server has a tool named "get_weather", it will be available as "prefix_get_weather". - - Resources are accessible with prefixed URIs using the resource_separator. + - Resources are accessible with prefixed URIs. Example: If server has a resource with URI "weather://forecast", it will be available as - "prefix+weather://forecast". - - Templates are accessible with prefixed URI templates using the resource_separator. + "weather://prefix/forecast". + - Templates are accessible with prefixed URI templates. Example: If server has a template with URI "weather://location/{id}", it will be available - as "prefix+weather://location/{id}". - - Prompts are accessible with prefixed names using the prompt_separator. + as "weather://prefix/location/{id}". + - Prompts are accessible with prefixed names. Example: If server has a prompt named "weather_prompt", it will be available as "prefix_weather_prompt". @@ -975,17 +976,41 @@ class FastMCP(Generic[LifespanResultT]): Args: prefix: Prefix to use for the mounted server's objects. server: The FastMCP server to mount. - tool_separator: Separator character for tool names (defaults to "_"). - resource_separator: Separator character for resource URIs (defaults to "+"). - prompt_separator: Separator character for prompt names (defaults to "_"). as_proxy: Whether to treat the mounted server as a proxy. If None (default), automatically determined based on whether the server has a custom lifespan (True if it has a custom lifespan, False otherwise). + tool_separator: Deprecated. Separator character for tool names. + resource_separator: Deprecated. Separator character for resource URIs. + prompt_separator: Deprecated. Separator character for prompt names. """ from fastmcp import Client from fastmcp.client.transports import FastMCPTransport from fastmcp.server.proxy import FastMCPProxy + if tool_separator is not None: + warnings.warn( + "The tool_separator parameter is deprecated and will be removed in a future version. " + "Tools are now prefixed using 'prefix_toolname' format.", + DeprecationWarning, + stacklevel=2, + ) + + if resource_separator is not None: + warnings.warn( + "The resource_separator parameter is deprecated and ignored. " + "Resource prefixes are now added using the protocol://prefix/path format.", + DeprecationWarning, + stacklevel=2, + ) + + if prompt_separator is not None: + warnings.warn( + "The prompt_separator parameter is deprecated and will be removed in a future version. " + "Prompts are now prefixed using 'prefix_promptname' format.", + DeprecationWarning, + stacklevel=2, + ) + # if as_proxy is not specified and the server has a custom lifespan, # we should treat it as a proxy if as_proxy is None: @@ -997,9 +1022,6 @@ class FastMCP(Generic[LifespanResultT]): mounted_server = MountedServer( server=server, prefix=prefix, - tool_separator=tool_separator, - resource_separator=resource_separator, - prompt_separator=prompt_separator, ) self._mounted_servers[prefix] = mounted_server self._cache.clear() @@ -1025,57 +1047,74 @@ class FastMCP(Generic[LifespanResultT]): future changes to the imported server will not be reflected in the importing server. Server-level configurations and lifespans are not imported. - When a server is mounted: - The tools are imported with prefixed names - using the tool_separator + When a server is imported: + - The tools are imported with prefixed names 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" + available as "prefix_get_weather" + - The resources are imported with prefixed URIs using the new format + Example: If server has a resource with URI "weather://forecast", it will + be available as "weather://prefix/forecast" + - The templates are imported with prefixed URI templates using the new format + Example: If server has a template with URI "weather://location/{id}", it will + be available as "weather://prefix/location/{id}" + - The prompts are imported with prefixed names + Example: If server has a prompt named "weather_prompt", it will be available as + "prefix_weather_prompt" Args: - 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 "_") + prefix: The prefix to use for the imported server + server: The FastMCP server to import + tool_separator: Deprecated. Separator for tool names. + resource_separator: Deprecated and ignored. Prefix is now + applied using the protocol://prefix/path format + prompt_separator: Deprecated. Separator for prompt names. """ - if tool_separator is None: - tool_separator = "_" - if resource_separator is None: - resource_separator = "+" - if prompt_separator is None: - prompt_separator = "_" + if tool_separator is not None: + warnings.warn( + "The tool_separator parameter is deprecated and will be removed in a future version. " + "Tools are now prefixed using 'prefix_toolname' format.", + DeprecationWarning, + stacklevel=2, + ) + + if resource_separator is not None: + warnings.warn( + "The resource_separator parameter is deprecated and ignored. " + "Resource prefixes are now added using the protocol://prefix/path format.", + DeprecationWarning, + stacklevel=2, + ) + + if prompt_separator is not None: + warnings.warn( + "The prompt_separator parameter is deprecated and will be removed in a future version. " + "Prompts are now prefixed using 'prefix_promptname' format.", + DeprecationWarning, + stacklevel=2, + ) # Import tools from the mounted server - tool_prefix = f"{prefix}{tool_separator}" + tool_prefix = f"{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}" - _validate_resource_prefix(resource_prefix) for key, resource in (await server.get_resources()).items(): - self._resource_manager.add_resource(resource, key=f"{resource_prefix}{key}") + prefixed_key = add_resource_prefix(key, prefix) + self._resource_manager.add_resource(resource, key=prefixed_key) + for key, template in (await server.get_resource_templates()).items(): - self._resource_manager.add_template(template, key=f"{resource_prefix}{key}") + prefixed_key = add_resource_prefix(key, prefix) + self._resource_manager.add_template(template, key=prefixed_key) # Import prompts from the mounted server - prompt_prefix = f"{prefix}{prompt_separator}" + prompt_prefix = f"{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}'") - logger.debug(f"Imported resources with prefix '{resource_prefix}'") - logger.debug(f"Imported templates with prefix '{resource_prefix}'") + logger.debug(f"Imported resources and templates with prefix '{prefix}/'") logger.debug(f"Imported prompts with prefix '{prompt_prefix}'") self._cache.clear() @@ -1194,84 +1233,157 @@ class FastMCP(Generic[LifespanResultT]): return cls.as_proxy(client, **settings) -def _validate_resource_prefix(prefix: str) -> None: - valid_resource = "resource://path/to/resource" - test_case = f"{prefix}{valid_resource}" - try: - AnyUrl(test_case) - except pydantic.ValidationError as e: - raise ValueError( - "Resource prefix or separator would result in an " - f"invalid resource URI (test case was {test_case!r}): {e}" - ) - - class MountedServer: def __init__( self, prefix: str, server: FastMCP[LifespanResultT], - tool_separator: str | None = None, - resource_separator: str | None = None, - prompt_separator: str | None = None, ): - if tool_separator is None: - tool_separator = "_" - if resource_separator is None: - resource_separator = "+" - if prompt_separator is None: - prompt_separator = "_" - - _validate_resource_prefix(f"{prefix}{resource_separator}") - self.server = server self.prefix = prefix - self.tool_separator = tool_separator - self.resource_separator = resource_separator - self.prompt_separator = prompt_separator async def get_tools(self) -> dict[str, Tool]: tools = await self.server.get_tools() - return { - f"{self.prefix}{self.tool_separator}{key}": tool - for key, tool in tools.items() - } + return {f"{self.prefix}_{key}": tool for key, tool in tools.items()} async def get_resources(self) -> dict[str, Resource]: resources = await self.server.get_resources() return { - f"{self.prefix}{self.resource_separator}{key}": resource + add_resource_prefix(key, self.prefix): resource for key, resource in resources.items() } async def get_resource_templates(self) -> dict[str, ResourceTemplate]: templates = await self.server.get_resource_templates() return { - f"{self.prefix}{self.resource_separator}{key}": template + add_resource_prefix(key, self.prefix): template for key, template in templates.items() } async def get_prompts(self) -> dict[str, Prompt]: prompts = await self.server.get_prompts() - return { - f"{self.prefix}{self.prompt_separator}{key}": prompt - for key, prompt in prompts.items() - } + return {f"{self.prefix}_{key}": prompt for key, prompt in prompts.items()} def match_tool(self, key: str) -> bool: - return key.startswith(f"{self.prefix}{self.tool_separator}") + return key.startswith(f"{self.prefix}_") def strip_tool_prefix(self, key: str) -> str: - return key.removeprefix(f"{self.prefix}{self.tool_separator}") + return key.removeprefix(f"{self.prefix}_") def match_resource(self, key: str) -> bool: - return key.startswith(f"{self.prefix}{self.resource_separator}") + return has_resource_prefix(key, self.prefix) def strip_resource_prefix(self, key: str) -> str: - return key.removeprefix(f"{self.prefix}{self.resource_separator}") + return remove_resource_prefix(key, self.prefix) def match_prompt(self, key: str) -> bool: - return key.startswith(f"{self.prefix}{self.prompt_separator}") + return key.startswith(f"{self.prefix}_") def strip_prompt_prefix(self, key: str) -> str: - return key.removeprefix(f"{self.prefix}{self.prompt_separator}") + return key.removeprefix(f"{self.prefix}_") + + +def add_resource_prefix(uri: str, prefix: str) -> str: + """Add a prefix to a resource URI. + + Args: + uri: The original resource URI + prefix: The prefix to add + + Returns: + The resource URI with the prefix added + + Examples: + >>> add_resource_prefix("resource://path/to/resource", "prefix") + "resource://prefix/path/to/resource" + >>> add_resource_prefix("resource:///absolute/path", "prefix") + "resource://prefix//absolute/path" + + Raises: + ValueError: If the URI doesn't match the expected protocol://path format + """ + if not prefix: + return uri + + # Split the URI into protocol and path + match = re.match(r"^([^:]+://)(.*?)$", uri) + if not match: + raise ValueError(f"Invalid URI format: {uri}. Expected protocol://path format.") + + protocol, path = match.groups() + + # Add the prefix to the path + return f"{protocol}{prefix}/{path}" + + +def remove_resource_prefix(uri: str, prefix: str) -> str: + """Remove a prefix from a resource URI. + + Args: + uri: The resource URI with a prefix + prefix: The prefix to remove + + Returns: + The resource URI with the prefix removed + + Examples: + >>> remove_resource_prefix("resource://prefix/path/to/resource", "prefix") + "resource://path/to/resource" + >>> remove_resource_prefix("resource://prefix//absolute/path", "prefix") + "resource:///absolute/path" + + Raises: + ValueError: If the URI doesn't match the expected protocol://path format + """ + if not prefix: + return uri + + # Split the URI into protocol and path + match = re.match(r"^([^:]+://)(.*?)$", uri) + if not match: + raise ValueError(f"Invalid URI format: {uri}. Expected protocol://path format.") + + protocol, path = match.groups() + + # Check if the path starts with the prefix followed by a / + prefix_pattern = f"^{re.escape(prefix)}/(.*?)$" + path_match = re.match(prefix_pattern, path) + if not path_match: + return uri + + # Return the URI without the prefix + return f"{protocol}{path_match.group(1)}" + + +def has_resource_prefix(uri: str, prefix: str) -> bool: + """Check if a resource URI has a specific prefix. + + Args: + uri: The resource URI to check + prefix: The prefix to look for + + Returns: + True if the URI has the specified prefix, False otherwise + + Examples: + >>> has_resource_prefix("resource://prefix/path/to/resource", "prefix") + True + >>> has_resource_prefix("resource://other/path/to/resource", "prefix") + False + + Raises: + ValueError: If the URI doesn't match the expected protocol://path format + """ + if not prefix: + return False + + # Split the URI into protocol and path + match = re.match(r"^([^:]+://)(.*?)$", uri) + if not match: + raise ValueError(f"Invalid URI format: {uri}. Expected protocol://path format.") + + _, path = match.groups() + + # Check if the path starts with the prefix followed by a / + prefix_pattern = f"^{re.escape(prefix)}/" + return bool(re.match(prefix_pattern, path)) diff --git a/tests/deprecated/__init__.py b/tests/deprecated/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_deprecated.py b/tests/deprecated/test_deprecated.py similarity index 56% rename from tests/test_deprecated.py rename to tests/deprecated/test_deprecated.py index 65f6fc71d..f9facd658 100644 --- a/tests/test_deprecated.py +++ b/tests/deprecated/test_deprecated.py @@ -96,3 +96,83 @@ def test_from_client_deprecation_warning(): server = FastMCP("TestServer") with pytest.warns(DeprecationWarning, match="from_client"): FastMCP.from_client(Client(server)) + + +def test_mount_tool_separator_deprecation_warning(): + """Test that using tool_separator in mount() raises a deprecation warning.""" + main_app = FastMCP("MainApp") + sub_app = FastMCP("SubApp") + + with pytest.warns( + DeprecationWarning, + match="The tool_separator parameter is deprecated and will be removed in a future version", + ): + main_app.mount("sub", sub_app, tool_separator="-") + + # Verify the separator is ignored and the default is used + @sub_app.tool() + def test_tool(): + return "test" + + mounted_server = main_app._mounted_servers["sub"] + assert mounted_server.match_tool("sub_test_tool") + assert not mounted_server.match_tool("sub-test_tool") + + +def test_mount_resource_separator_deprecation_warning(): + """Test that using resource_separator in mount() raises a deprecation warning.""" + main_app = FastMCP("MainApp") + sub_app = FastMCP("SubApp") + + with pytest.warns( + DeprecationWarning, + match="The resource_separator parameter is deprecated and ignored", + ): + main_app.mount("sub", sub_app, resource_separator="+") + + +def test_mount_prompt_separator_deprecation_warning(): + """Test that using prompt_separator in mount() raises a deprecation warning.""" + main_app = FastMCP("MainApp") + sub_app = FastMCP("SubApp") + + with pytest.warns( + DeprecationWarning, + match="The prompt_separator parameter is deprecated and will be removed in a future version", + ): + main_app.mount("sub", sub_app, prompt_separator="-") + + # Verify the separator is ignored and the default is used + @sub_app.prompt() + def test_prompt(): + return "test" + + mounted_server = main_app._mounted_servers["sub"] + assert mounted_server.match_prompt("sub_test_prompt") + assert not mounted_server.match_prompt("sub-test_prompt") + + +async def test_import_server_separator_deprecation_warnings(): + """Test that using separators in import_server() raises deprecation warnings.""" + main_app = FastMCP("MainApp") + sub_app = FastMCP("SubApp") + + with pytest.warns( + DeprecationWarning, + match="The tool_separator parameter is deprecated and will be removed in a future version", + ): + await main_app.import_server("sub", sub_app, tool_separator="-") + + main_app = FastMCP("MainApp") + with pytest.warns( + DeprecationWarning, + match="The resource_separator parameter is deprecated and ignored", + ): + await main_app.import_server("sub", sub_app, resource_separator="+") + + main_app = FastMCP("MainApp") + with pytest.warns( + DeprecationWarning, + match="The prompt_separator parameter is deprecated and will be removed in a future version", + ): + await main_app.import_server("sub", sub_app, prompt_separator="-") diff --git a/tests/deprecated/test_mount_separators.py b/tests/deprecated/test_mount_separators.py new file mode 100644 index 000000000..d7114a99d --- /dev/null +++ b/tests/deprecated/test_mount_separators.py @@ -0,0 +1,85 @@ +"""Tests for the deprecated separator parameters in mount() and import_server() methods.""" + +import pytest + +from fastmcp import FastMCP + + +def test_mount_tool_separator_deprecation_warning(): + """Test that using tool_separator in mount() raises a deprecation warning.""" + main_app = FastMCP("MainApp") + sub_app = FastMCP("SubApp") + + with pytest.warns( + DeprecationWarning, + match="The tool_separator parameter is deprecated and will be removed in a future version", + ): + main_app.mount("sub", sub_app, tool_separator="-") + + # Verify the separator is ignored and the default is used + @sub_app.tool() + def test_tool(): + return "test" + + mounted_server = main_app._mounted_servers["sub"] + assert mounted_server.match_tool("sub_test_tool") + assert not mounted_server.match_tool("sub-test_tool") + + +def test_mount_resource_separator_deprecation_warning(): + """Test that using resource_separator in mount() raises a deprecation warning.""" + main_app = FastMCP("MainApp") + sub_app = FastMCP("SubApp") + + with pytest.warns( + DeprecationWarning, + match="The resource_separator parameter is deprecated and ignored", + ): + main_app.mount("sub", sub_app, resource_separator="+") + + +def test_mount_prompt_separator_deprecation_warning(): + """Test that using prompt_separator in mount() raises a deprecation warning.""" + main_app = FastMCP("MainApp") + sub_app = FastMCP("SubApp") + + with pytest.warns( + DeprecationWarning, + match="The prompt_separator parameter is deprecated and will be removed in a future version", + ): + main_app.mount("sub", sub_app, prompt_separator="-") + + # Verify the separator is ignored and the default is used + @sub_app.prompt() + def test_prompt(): + return "test" + + mounted_server = main_app._mounted_servers["sub"] + assert mounted_server.match_prompt("sub_test_prompt") + assert not mounted_server.match_prompt("sub-test_prompt") + + +async def test_import_server_separator_deprecation_warnings(): + """Test that using separators in import_server() raises deprecation warnings.""" + main_app = FastMCP("MainApp") + sub_app = FastMCP("SubApp") + + with pytest.warns( + DeprecationWarning, + match="The tool_separator parameter is deprecated and will be removed in a future version", + ): + await main_app.import_server("sub", sub_app, tool_separator="-") + + main_app = FastMCP("MainApp") + with pytest.warns( + DeprecationWarning, + match="The resource_separator parameter is deprecated and ignored", + ): + await main_app.import_server("sub", sub_app, resource_separator="+") + + main_app = FastMCP("MainApp") + with pytest.warns( + DeprecationWarning, + match="The prompt_separator parameter is deprecated and will be removed in a future version", + ): + await main_app.import_server("sub", sub_app, prompt_separator="-") diff --git a/tests/server/test_import_server.py b/tests/server/test_import_server.py index ef03ceffc..93512f23d 100644 --- a/tests/server/test_import_server.py +++ b/tests/server/test_import_server.py @@ -1,7 +1,6 @@ import json from urllib.parse import quote -import pytest from mcp.types import TextContent, TextResourceContents from fastmcp.client.client import Client @@ -103,7 +102,7 @@ async def test_import_with_resources(): 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 + assert "data://data/users" in main_app._resource_manager._resources async def test_import_with_resource_templates(): @@ -121,7 +120,7 @@ async def test_import_with_resource_templates(): 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 + assert "users://api/{user_id}/profile" in main_app._resource_manager._templates async def test_import_with_prompts(): @@ -163,8 +162,8 @@ async def test_import_multiple_resource_templates(): 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 + assert "weather://data/{city}" in main_app._resource_manager._templates + assert "news://content/{category}" in main_app._resource_manager._templates async def test_import_multiple_prompts(): @@ -356,11 +355,11 @@ async def test_import_with_proxy_resources(): # Access the resource through the main app with the prefixed key async with Client(main_app) as client: - result = await client.read_resource("api+config://settings") + result = await client.read_resource("config://api/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" + content = json.loads(result[0].text) + assert content["api_key"] == "12345" + assert content["base_url"] == "https://api.example.com" async def test_import_with_proxy_resource_templates(): @@ -387,30 +386,27 @@ async def test_import_with_proxy_resource_templates(): quoted_name = quote("John Doe", safe="") quoted_email = quote("john@example.com", safe="") async with Client(main_app) as client: - result = await client.read_resource(f"api+user://{quoted_name}/{quoted_email}") + result = await client.read_resource(f"user://api/{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" + content = json.loads(result[0].text) + assert content["name"] == "John Doe" + assert content["email"] == "john@example.com" async def test_import_invalid_resource_prefix(): main_app = FastMCP("MainApp") api_app = FastMCP("APIApp") - with pytest.raises( - ValueError, - match="Resource prefix or separator would result in an invalid resource URI", - ): - await main_app.import_server("api_sub", api_app) + # This test doesn't apply anymore with the new prefix format since we're not validating + # the protocol://prefix/path format + # Just import the server to maintain test coverage without deprecated parameters + await main_app.import_server("api_sub", api_app) async def test_import_invalid_resource_separator(): main_app = FastMCP("MainApp") api_app = FastMCP("APIApp") - with pytest.raises( - ValueError, - match="Resource prefix or separator would result in an invalid resource URI", - ): - await main_app.import_server("api", api_app, resource_separator="_") + # This test is for maintaining coverage for importing with prefixes + # We no longer pass the deprecated resource_separator parameter + await main_app.import_server("api", api_app) diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py index a9eeb5a8c..598cf3aa0 100644 --- a/tests/server/test_mount.py +++ b/tests/server/test_mount.py @@ -39,7 +39,7 @@ class TestBasicMount: 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.""" + """Test mounting with a custom tool separator (deprecated but still supported).""" main_app = FastMCP("MainApp") sub_app = FastMCP("SubApp") @@ -47,15 +47,15 @@ class TestBasicMount: def greet(name: str) -> str: return f"Hello, {name}!" - # Mount with custom separator - main_app.mount("sub", sub_app, tool_separator="-") + # Mount without custom separator - custom separators are deprecated + main_app.mount("sub", sub_app) - # Tool should be accessible with custom separator + # Tool should be accessible with the default separator tools = await main_app.get_tools() - assert "sub-greet" in tools + assert "sub_greet" in tools # Call the tool - result = await main_app._mcp_call_tool("sub-greet", {"name": "World"}) + result = await main_app._mcp_call_tool("sub_greet", {"name": "World"}) assert isinstance(result[0], TextContent) assert result[0].text == "Hello, World!" @@ -63,21 +63,17 @@ class TestBasicMount: main_app = FastMCP("MainApp") api_app = FastMCP("APIApp") - with pytest.raises( - ValueError, - match="Resource prefix or separator would result in an invalid resource URI", - ): - main_app.mount("api_sub", api_app) + # This test doesn't apply anymore with the new prefix format + # just mount the server to maintain test coverage + main_app.mount("api:sub", api_app) async def test_mount_invalid_resource_separator(self): main_app = FastMCP("MainApp") api_app = FastMCP("APIApp") - with pytest.raises( - ValueError, - match="Resource prefix or separator would result in an invalid resource URI", - ): - main_app.mount("api", api_app, resource_separator="_") + # This test doesn't apply anymore with the new prefix format + # Mount without deprecated parameters + main_app.mount("api", api_app) async def test_unmount_server(self): """Test unmounting a server removes access to its tools.""" @@ -114,12 +110,12 @@ class TestBasicMount: def sub_tool() -> str: return "This is from the sub app" - main_app.mount( - prefix="", server=sub_app, tool_separator="", resource_separator="" - ) + # Mount with empty prefix but without deprecated separators + main_app.mount(prefix="", server=sub_app) tools = await main_app.get_tools() - assert "sub_tool" in tools + # With empty prefix, the format is now "_sub_tool" instead of "sub_tool" + assert "_sub_tool" in tools class TestMultipleServerMount: @@ -259,12 +255,13 @@ class TestResourcesAndTemplates: # 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) + assert "data://data/users" in resources + # Check that resource can be accessed async with Client(main_app) as client: - resource = await client.read_resource("data+data://users") - assert isinstance(resource[0], TextResourceContents) - assert resource[0].text == '[\n "user1",\n "user2"\n]' + result = await client.read_resource("data://data/users") + assert isinstance(result[0], TextResourceContents) + assert json.loads(result[0].text) == ["user1", "user2"] async def test_mount_with_resource_templates(self): """Test mounting a server with resource templates.""" @@ -280,14 +277,15 @@ class TestResourcesAndTemplates: # 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) + assert "users://api/{user_id}/profile" 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" + # Check template instantiation + async with Client(main_app) as client: + result = await client.read_resource("users://api/123/profile") + assert isinstance(result[0], TextResourceContents) + profile = json.loads(result[0].text) + assert profile["id"] == "123" + assert profile["name"] == "User 123" async def test_adding_resource_after_mounting(self): """Test adding a resource after mounting.""" @@ -304,13 +302,14 @@ class TestResourcesAndTemplates: # 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) + assert "data://data/config" 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" + # Check access to the resource + async with Client(main_app) as client: + result = await client.read_resource("data://data/config") + assert isinstance(result[0], TextResourceContents) + config = json.loads(result[0].text) + assert config["version"] == "1.0" class TestPrompts: @@ -437,7 +436,7 @@ class TestProxyServer: main_app.mount("proxy", proxy_server) # Resource should be accessible through main app - result = await main_app._mcp_read_resource("proxy+config://settings") + result = await main_app._mcp_read_resource("config://proxy/settings") assert isinstance(result[0], ReadResourceContents) config = json.loads(result[0].content) assert config["api_key"] == "12345" diff --git a/tests/server/test_openapi.py b/tests/server/test_openapi.py index 7bd7d5d34..3149ac19c 100644 --- a/tests/server/test_openapi.py +++ b/tests/server/test_openapi.py @@ -927,32 +927,10 @@ class TestMountFastMCP: assert len(resources) == 4 # Updated to account for new search endpoint # We're checking the key used by mcp to store the resource # The prefixed URI is used as the key, but the resource's original uri is preserved - prefixed_uri = "fastapi+resource://openapi/get_users_users_get" + prefixed_uri = "resource://fastapi/openapi/get_users_users_get" resource = mcp._resource_manager.get_resources().get(prefixed_uri) assert resource is not None - # Check that templates are available with prefixed URIs - async with Client(mcp) as client: - templates = await client.list_resource_templates() - assert len(templates) == 2 - assert templates[0].name == "get_user_users__user_id__get" - prefixed_template_uri = ( - r"fastapi+resource://openapi/get_user_users__user_id__get/{user_id}" - ) - template = mcp._resource_manager.get_templates().get(prefixed_template_uri) - assert template is not None - - # Check that tools are available with prefixed names - async with Client(mcp) as client: - tools = await client.list_tools() - assert len(tools) == 2 - assert tools[0].name == "fastapi_create_user_users_post" - assert tools[1].name == "fastapi_update_user_name_users__user_id__name_patch" - - async with Client(mcp) as client: - prompts = await client.list_prompts() - assert len(prompts) == 0 - async def test_empty_query_parameters_not_sent( fastapi_app: FastAPI, api_client: httpx.AsyncClient diff --git a/tests/server/test_server.py b/tests/server/test_server.py index 4a7180b41..1f420213c 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -10,6 +10,12 @@ from pydantic import Field from fastmcp import Client, FastMCP from fastmcp.exceptions import NotFoundError +from fastmcp.server.server import ( + MountedServer, + add_resource_prefix, + has_resource_prefix, + remove_resource_prefix, +) class TestCreateServer: @@ -754,3 +760,287 @@ class TestPromptDecorator: assert len(prompts_dict) == 1 prompt = prompts_dict["sample_prompt"] assert prompt.tags == {"example", "test-tag"} + + +class TestResourcePrefixHelpers: + @pytest.mark.parametrize( + "uri,prefix,expected", + [ + # Normal paths + ( + "resource://path/to/resource", + "prefix", + "resource://prefix/path/to/resource", + ), + # Absolute paths (with triple slash) + ("resource:///absolute/path", "prefix", "resource://prefix//absolute/path"), + # Empty prefix should return the original URI + ("resource://path/to/resource", "", "resource://path/to/resource"), + # Different protocols + ("file://path/to/file", "prefix", "file://prefix/path/to/file"), + ("http://example.com/path", "prefix", "http://prefix/example.com/path"), + # Prefixes with special characters + ( + "resource://path/to/resource", + "pre.fix", + "resource://pre.fix/path/to/resource", + ), + ( + "resource://path/to/resource", + "pre/fix", + "resource://pre/fix/path/to/resource", + ), + # Empty paths + ("resource://", "prefix", "resource://prefix/"), + ], + ) + def test_add_resource_prefix(self, uri, prefix, expected): + """Test that add_resource_prefix correctly adds prefixes to URIs.""" + result = add_resource_prefix(uri, prefix) + assert result == expected + + @pytest.mark.parametrize( + "invalid_uri", + [ + "not-a-uri", + "resource:no-slashes", + "missing-protocol", + "http:/missing-slash", + ], + ) + def test_add_resource_prefix_invalid_uri(self, invalid_uri): + """Test that add_resource_prefix raises ValueError for invalid URIs.""" + with pytest.raises(ValueError, match="Invalid URI format"): + add_resource_prefix(invalid_uri, "prefix") + + @pytest.mark.parametrize( + "uri,prefix,expected", + [ + # Normal paths + ( + "resource://prefix/path/to/resource", + "prefix", + "resource://path/to/resource", + ), + # Absolute paths (with triple slash) + ("resource://prefix//absolute/path", "prefix", "resource:///absolute/path"), + # URI without the expected prefix should return the original URI + ( + "resource://other/path/to/resource", + "prefix", + "resource://other/path/to/resource", + ), + # Empty prefix should return the original URI + ("resource://path/to/resource", "", "resource://path/to/resource"), + # Different protocols + ("file://prefix/path/to/file", "prefix", "file://path/to/file"), + # Prefixes with special characters (that need escaping in regex) + ( + "resource://pre.fix/path/to/resource", + "pre.fix", + "resource://path/to/resource", + ), + ( + "resource://pre/fix/path/to/resource", + "pre/fix", + "resource://path/to/resource", + ), + # Empty paths + ("resource://prefix/", "prefix", "resource://"), + ], + ) + def test_remove_resource_prefix(self, uri, prefix, expected): + """Test that remove_resource_prefix correctly removes prefixes from URIs.""" + result = remove_resource_prefix(uri, prefix) + assert result == expected + + @pytest.mark.parametrize( + "invalid_uri", + [ + "not-a-uri", + "resource:no-slashes", + "missing-protocol", + "http:/missing-slash", + ], + ) + def test_remove_resource_prefix_invalid_uri(self, invalid_uri): + """Test that remove_resource_prefix raises ValueError for invalid URIs.""" + with pytest.raises(ValueError, match="Invalid URI format"): + remove_resource_prefix(invalid_uri, "prefix") + + @pytest.mark.parametrize( + "uri,prefix,expected", + [ + # URI with prefix + ("resource://prefix/path/to/resource", "prefix", True), + # URI with another prefix + ("resource://other/path/to/resource", "prefix", False), + # URI with prefix as a substring but not at path start + ("resource://path/prefix/resource", "prefix", False), + # Empty prefix + ("resource://path/to/resource", "", False), + # Different protocols + ("file://prefix/path/to/file", "prefix", True), + # Prefix with special characters + ("resource://pre.fix/path/to/resource", "pre.fix", True), + # Empty paths + ("resource://prefix/", "prefix", True), + ], + ) + def test_has_resource_prefix(self, uri, prefix, expected): + """Test that has_resource_prefix correctly identifies prefixes in URIs.""" + result = has_resource_prefix(uri, prefix) + assert result == expected + + @pytest.mark.parametrize( + "invalid_uri", + [ + "not-a-uri", + "resource:no-slashes", + "missing-protocol", + "http:/missing-slash", + ], + ) + def test_has_resource_prefix_invalid_uri(self, invalid_uri): + """Test that has_resource_prefix raises ValueError for invalid URIs.""" + with pytest.raises(ValueError, match="Invalid URI format"): + has_resource_prefix(invalid_uri, "prefix") + + +class TestResourcePrefixMounting: + """Test resource prefixing in mounted servers.""" + + async def test_mounted_server_resource_prefixing(self): + """Test that resources in mounted servers use the correct prefix format.""" + # Create a server with resources + server = FastMCP(name="ResourceServer") + + @server.resource("resource://test-resource") + def get_resource(): + return "Resource content" + + @server.resource("resource:///absolute/path") + def get_absolute_resource(): + return "Absolute resource content" + + @server.resource("resource://{param}/template") + def get_template_resource(param: str): + return f"Template resource with {param}" + + # Create a main server and mount the resource server + main_server = FastMCP(name="MainServer") + main_server.mount("prefix", server) + + # Check that the resources are mounted with the correct prefixes + resources = await main_server.get_resources() + templates = await main_server.get_resource_templates() + + assert "resource://prefix/test-resource" in resources + assert "resource://prefix//absolute/path" in resources + assert "resource://prefix/{param}/template" in templates + + # Test that prefixed resources can be accessed + async with Client(main_server) as client: + # Regular resource + result = await client.read_resource("resource://prefix/test-resource") + assert isinstance(result[0], TextResourceContents) + assert result[0].text == "Resource content" + + # Absolute path resource + result = await client.read_resource("resource://prefix//absolute/path") + assert isinstance(result[0], TextResourceContents) + assert result[0].text == "Absolute resource content" + + # Template resource + result = await client.read_resource( + "resource://prefix/param-value/template" + ) + assert isinstance(result[0], TextResourceContents) + assert result[0].text == "Template resource with param-value" + + @pytest.mark.parametrize( + "uri,prefix,expected_match,expected_strip", + [ + # Regular resource + ( + "resource://prefix/path/to/resource", + "prefix", + True, + "resource://path/to/resource", + ), + # Absolute path + ( + "resource://prefix//absolute/path", + "prefix", + True, + "resource:///absolute/path", + ), + # Non-matching prefix + ( + "resource://other/path/to/resource", + "prefix", + False, + "resource://other/path/to/resource", + ), + # Different protocol + ("http://prefix/example.com", "prefix", True, "http://example.com"), + ], + ) + async def test_mounted_server_matching_and_stripping( + self, uri, prefix, expected_match, expected_strip + ): + """Test that MountedServer correctly matches and strips resource prefixes.""" + # Create a basic server to mount + server = FastMCP() + mounted = MountedServer(prefix=prefix, server=server) + + # Test matching + assert mounted.match_resource(uri) == expected_match + + # Test stripping + assert mounted.strip_resource_prefix(uri) == expected_strip + + async def test_import_server_with_new_prefix_format(self): + """Test that import_server correctly uses the new prefix format.""" + # Create a server with resources + source_server = FastMCP(name="SourceServer") + + @source_server.resource("resource://test-resource") + def get_resource(): + return "Resource content" + + @source_server.resource("resource:///absolute/path") + def get_absolute_resource(): + return "Absolute resource content" + + @source_server.resource("resource://{param}/template") + def get_template_resource(param: str): + return f"Template resource with {param}" + + # Create target server and import the source server + target_server = FastMCP(name="TargetServer") + await target_server.import_server("imported", source_server) + + # Check that the resources were imported with the correct prefixes + resources = await target_server.get_resources() + templates = await target_server.get_resource_templates() + + assert "resource://imported/test-resource" in resources + assert "resource://imported//absolute/path" in resources + assert "resource://imported/{param}/template" in templates + + # Verify we can access the resources + async with Client(target_server) as client: + result = await client.read_resource("resource://imported/test-resource") + assert isinstance(result[0], TextResourceContents) + assert result[0].text == "Resource content" + + result = await client.read_resource("resource://imported//absolute/path") + assert isinstance(result[0], TextResourceContents) + assert result[0].text == "Absolute resource content" + + result = await client.read_resource( + "resource://imported/param-value/template" + ) + assert isinstance(result[0], TextResourceContents) + assert result[0].text == "Template resource with param-value" From e2ac996b797be7a86001977a05ac60e2d082cf4f Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 20 May 2025 20:33:22 -0400 Subject: [PATCH 042/114] Update server.py --- src/fastmcp/server/server.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index ff589bbb4..1385bac04 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -988,6 +988,7 @@ class FastMCP(Generic[LifespanResultT]): from fastmcp.server.proxy import FastMCPProxy if tool_separator is not None: + # Deprecated since 2.3.6 warnings.warn( "The tool_separator parameter is deprecated and will be removed in a future version. " "Tools are now prefixed using 'prefix_toolname' format.", @@ -996,6 +997,7 @@ class FastMCP(Generic[LifespanResultT]): ) if resource_separator is not None: + # Deprecated since 2.3.6 warnings.warn( "The resource_separator parameter is deprecated and ignored. " "Resource prefixes are now added using the protocol://prefix/path format.", @@ -1004,6 +1006,7 @@ class FastMCP(Generic[LifespanResultT]): ) if prompt_separator is not None: + # Deprecated since 2.3.6 warnings.warn( "The prompt_separator parameter is deprecated and will be removed in a future version. " "Prompts are now prefixed using 'prefix_promptname' format.", @@ -1070,6 +1073,7 @@ class FastMCP(Generic[LifespanResultT]): prompt_separator: Deprecated. Separator for prompt names. """ if tool_separator is not None: + # Deprecated since 2.3.6 warnings.warn( "The tool_separator parameter is deprecated and will be removed in a future version. " "Tools are now prefixed using 'prefix_toolname' format.", @@ -1078,6 +1082,7 @@ class FastMCP(Generic[LifespanResultT]): ) if resource_separator is not None: + # Deprecated since 2.3.6 warnings.warn( "The resource_separator parameter is deprecated and ignored. " "Resource prefixes are now added using the protocol://prefix/path format.", @@ -1086,6 +1091,7 @@ class FastMCP(Generic[LifespanResultT]): ) if prompt_separator is not None: + # Deprecated since 2.3.6 warnings.warn( "The prompt_separator parameter is deprecated and will be removed in a future version. " "Prompts are now prefixed using 'prefix_promptname' format.", From fa1c94ccdc2fefa37133576a00b529817b6ba29a Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 20 May 2025 20:48:16 -0400 Subject: [PATCH 043/114] Support creating clients from mcpconfig --- src/fastmcp/client/client.py | 10 ++++- src/fastmcp/client/transports.py | 57 ++++++++++++++++++++++------- src/fastmcp/server/proxy.py | 8 ---- src/fastmcp/server/server.py | 2 + src/fastmcp/utilities/exceptions.py | 1 + src/fastmcp/utilities/mcp_config.py | 14 ------- tests/client/test_client.py | 33 ++++++++++++++--- tests/utilities/test_mcp_config.py | 50 +++++++++++++++++++++++++ 8 files changed, 132 insertions(+), 43 deletions(-) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 98c3298be..19552ea35 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -25,6 +25,7 @@ from fastmcp.client.sampling import SamplingHandler, create_sampling_callback from fastmcp.exceptions import ToolError from fastmcp.server import FastMCP from fastmcp.utilities.exceptions import get_catch_handlers +from fastmcp.utilities.mcp_config import MCPConfig from .transports import ClientTransport, SessionKwargs, infer_transport @@ -53,6 +54,7 @@ class Client: - FastMCP: In-process FastMCP server - AnyUrl | str: URL to connect to - Path: File path for local socket + - MCPConfig: MCP server configuration - dict: Transport configuration roots: Optional RootsList or RootsHandler for filesystem access sampling_handler: Optional handler for sampling requests @@ -77,7 +79,13 @@ class Client: def __init__( self, - transport: ClientTransport | FastMCP | AnyUrl | Path | dict[str, Any] | str, + transport: ClientTransport + | FastMCP + | AnyUrl + | Path + | MCPConfig + | dict[str, Any] + | str, # Common args roots: RootsList | RootsHandler | None = None, sampling_handler: SamplingHandler | None = None, diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 9bf2cac07..f16724cf3 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -24,6 +24,7 @@ from pydantic import AnyUrl from typing_extensions import Unpack from fastmcp.server import FastMCP as FastMCPServer +from fastmcp.server.server import FastMCP from fastmcp.utilities.logging import get_logger from fastmcp.utilities.mcp_config import MCPConfig, infer_transport_type_from_url @@ -74,7 +75,7 @@ class ClientTransport(abc.ABC): A mcp.ClientSession instance. """ raise NotImplementedError - yield None # type: ignore + yield # type: ignore def __repr__(self) -> str: # Basic representation for subclasses @@ -455,7 +456,7 @@ class FastMCPTransport(ClientTransport): """ def __init__(self, mcp: FastMCPServer): - self._fastmcp = mcp # Can be FastMCP or MCPServer + self.server = mcp # Can be FastMCP or MCPServer @contextlib.asynccontextmanager async def connect_session( @@ -463,13 +464,50 @@ class FastMCPTransport(ClientTransport): ) -> 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, + server=self.server._mcp_server, **session_kwargs, ) as session: yield session def __repr__(self) -> str: - return f"" + return f"" + + +class MCPConfigTransport(ClientTransport): + """Transport for running MCPConfig.""" + + def __init__(self, config: MCPConfig | dict): + from fastmcp.client.client import Client + + if isinstance(config, dict): + config = MCPConfig.from_dict(config) + self.config = config + + # if there's exactly one server, create a client for that server + if len(self.config.mcpServers) == 1: + self.transport = list(self.config.mcpServers.values())[0].to_transport() + + # otherwise create a composite client + else: + composite_server = FastMCP() + + for name, server in self.config.mcpServers.items(): + server_client = Client(transport=server.to_transport()) + composite_server.mount( + prefix=name, server=FastMCP.as_proxy(server_client) + ) + + self.transport = FastMCPTransport(mcp=composite_server) + + @contextlib.asynccontextmanager + async def connect_session( + self, **session_kwargs: Unpack[SessionKwargs] + ) -> AsyncIterator[ClientSession]: + async with self.transport.connect_session(**session_kwargs) as session: + yield session + + def __repr__(self) -> str: + return f"" def infer_transport( @@ -519,16 +557,7 @@ def infer_transport( # if the transport is a config dict or MCPConfig elif isinstance(transport, dict | MCPConfig): - if isinstance(transport, dict): - config = MCPConfig.from_dict(transport) - else: - config = transport - inferred_transports = config.to_transports() - if len(inferred_transports) > 1: - raise ValueError( - "Invalid transport dictionary: multiple servers found - only one expected" - ) - inferred_transport = list(inferred_transports.values())[0] + inferred_transport = MCPConfigTransport(config=transport) # the transport is an unknown type else: diff --git a/src/fastmcp/server/proxy.py b/src/fastmcp/server/proxy.py index 9fcdcdb26..8f7123bab 100644 --- a/src/fastmcp/server/proxy.py +++ b/src/fastmcp/server/proxy.py @@ -25,7 +25,6 @@ from fastmcp.server.context import Context from fastmcp.server.server import FastMCP from fastmcp.tools.tool import Tool from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.mcp_config import MCPConfig if TYPE_CHECKING: from fastmcp.server import Context @@ -178,13 +177,6 @@ class FastMCPProxy(FastMCP): super().__init__(**kwargs) self.client = client - @classmethod - async def from_mcp_config(cls, config: MCPConfig | dict) -> FastMCPProxy: - if isinstance(config, dict): - config = MCPConfig.from_dict(config) - clients = config.to_clients() - return cls(client=clients[list(clients.keys())[0]]) - async def get_tools(self) -> dict[str, Tool]: tools = await super().get_tools() diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 1385bac04..42747d6a1 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -58,6 +58,7 @@ from fastmcp.tools.tool import Tool from fastmcp.utilities.cache import TimedCache from fastmcp.utilities.decorators import DecoratedFunction from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.mcp_config import MCPConfig if TYPE_CHECKING: from fastmcp.client import Client @@ -1203,6 +1204,7 @@ class FastMCP(Generic[LifespanResultT]): | FastMCP[Any] | AnyUrl | Path + | MCPConfig | dict[str, Any] | str, **settings: Any, diff --git a/src/fastmcp/utilities/exceptions.py b/src/fastmcp/utilities/exceptions.py index e50dc57b0..8cbd4b7f5 100644 --- a/src/fastmcp/utilities/exceptions.py +++ b/src/fastmcp/utilities/exceptions.py @@ -18,6 +18,7 @@ def iter_exc(group: BaseExceptionGroup): def _exception_handler(group: BaseExceptionGroup): + print(list(iter_exc(group))) for leaf in iter_exc(group): if isinstance(leaf, httpx.ConnectTimeout): raise McpError( diff --git a/src/fastmcp/utilities/mcp_config.py b/src/fastmcp/utilities/mcp_config.py index 7330bd65f..19905f701 100644 --- a/src/fastmcp/utilities/mcp_config.py +++ b/src/fastmcp/utilities/mcp_config.py @@ -6,7 +6,6 @@ from urllib.parse import urlparse from pydantic import AnyUrl, BaseModel, Field if TYPE_CHECKING: - from fastmcp.client.client import Client from fastmcp.client.transports import ( SSETransport, StdioTransport, @@ -75,16 +74,3 @@ class MCPConfig(BaseModel): @classmethod def from_dict(cls, config: dict[str, Any]) -> MCPConfig: return cls(mcpServers=config.get("mcpServers", config)) - - def to_transports( - self, - ) -> dict[str, StdioTransport | StreamableHttpTransport | SSETransport]: - return {name: server.to_transport() for name, server in self.mcpServers.items()} - - def to_clients(self) -> dict[str, Client]: - from fastmcp.client.client import Client - - return { - name: Client(transport=transport) - for name, transport in self.to_transports().items() - } diff --git a/tests/client/test_client.py b/tests/client/test_client.py index e63f80179..62bddf0ab 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -9,6 +9,7 @@ from pydantic import AnyUrl from fastmcp.client import Client from fastmcp.client.transports import ( FastMCPTransport, + MCPConfigTransport, SSETransport, StdioTransport, StreamableHttpTransport, @@ -652,9 +653,10 @@ class TestInferTransport: } } transport = infer_transport(config) - assert isinstance(transport, SSETransport) - assert transport.url == "http://localhost:8000/sse" - assert transport.headers == {"Authorization": "Bearer 123"} + assert isinstance(transport, MCPConfigTransport) + assert isinstance(transport.transport, SSETransport) + assert transport.transport.url == "http://localhost:8000/sse" + assert transport.transport.headers == {"Authorization": "Bearer 123"} def test_infer_local_transport_from_config(self): config = { @@ -666,6 +668,25 @@ class TestInferTransport: } } transport = infer_transport(config) - assert isinstance(transport, StdioTransport) - assert transport.command == "echo" - assert transport.args == ["hello"] + assert isinstance(transport, MCPConfigTransport) + assert isinstance(transport.transport, StdioTransport) + assert transport.transport.command == "echo" + assert transport.transport.args == ["hello"] + + def test_infer_composite_client(config): + config = { + "mcpServers": { + "local": { + "command": "echo", + "args": ["hello"], + }, + "remote": { + "url": "http://localhost:8000/sse", + "headers": {"Authorization": "Bearer 123"}, + }, + } + } + transport = infer_transport(config) + assert isinstance(transport, MCPConfigTransport) + assert isinstance(transport.transport, FastMCPTransport) + assert len(transport.transport.server._mounted_servers) == 2 diff --git a/tests/utilities/test_mcp_config.py b/tests/utilities/test_mcp_config.py index 011a0574d..627a149f1 100644 --- a/tests/utilities/test_mcp_config.py +++ b/tests/utilities/test_mcp_config.py @@ -1,3 +1,9 @@ +import inspect +from pathlib import Path + +from mcp.types import TextContent + +from fastmcp.client.client import Client from fastmcp.client.transports import ( SSETransport, StdioTransport, @@ -90,3 +96,47 @@ def test_parse_multiple_servers(): assert mcp_config.mcpServers["test_server_2"].command == "echo" assert mcp_config.mcpServers["test_server_2"].args == ["hello"] assert mcp_config.mcpServers["test_server_2"].env == {"TEST": "test"} + + +async def test_multi_client(tmp_path: Path): + server_script = inspect.cleandoc(""" + from fastmcp import FastMCP + + mcp = FastMCP() + + @mcp.tool() + def add(a: int, b: int) -> int: + return a + b + + if __name__ == '__main__': + mcp.run() + """) + + script_path = tmp_path / "test.py" + script_path.write_text(server_script) + + config = { + "mcpServers": { + "test_1": { + "command": "python", + "args": [str(script_path)], + }, + "test_2": { + "command": "python", + "args": [str(script_path)], + }, + } + } + + client = Client(config) + + async with client: + tools = await client.list_tools() + assert len(tools) == 2 + + result_1 = await client.call_tool("test_1_add", {"a": 1, "b": 2}) + result_2 = await client.call_tool("test_2_add", {"a": 1, "b": 2}) + assert isinstance(result_1[0], TextContent) + assert result_1[0].text == "3" + assert isinstance(result_2[0], TextContent) + assert result_2[0].text == "3" From 4ee9d3a9490ab12d16e259fac79e09b0ea219e0b Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 20 May 2025 20:52:10 -0400 Subject: [PATCH 044/114] Compile URI pattern --- src/fastmcp/server/server.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 1385bac04..462b18476 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -68,6 +68,9 @@ logger = get_logger(__name__) DuplicateBehavior = Literal["warn", "error", "replace", "ignore"] +# Compiled URI parsing regex to split a URI into protocol and path components +URI_PATTERN = re.compile(r"^([^:]+://)(.*?)$") + @asynccontextmanager async def default_lifespan(server: FastMCP[LifespanResultT]) -> AsyncIterator[Any]: @@ -1312,7 +1315,7 @@ def add_resource_prefix(uri: str, prefix: str) -> str: return uri # Split the URI into protocol and path - match = re.match(r"^([^:]+://)(.*?)$", uri) + match = URI_PATTERN.match(uri) if not match: raise ValueError(f"Invalid URI format: {uri}. Expected protocol://path format.") @@ -1345,7 +1348,7 @@ def remove_resource_prefix(uri: str, prefix: str) -> str: return uri # Split the URI into protocol and path - match = re.match(r"^([^:]+://)(.*?)$", uri) + match = URI_PATTERN.match(uri) if not match: raise ValueError(f"Invalid URI format: {uri}. Expected protocol://path format.") @@ -1384,7 +1387,7 @@ def has_resource_prefix(uri: str, prefix: str) -> bool: return False # Split the URI into protocol and path - match = re.match(r"^([^:]+://)(.*?)$", uri) + match = URI_PATTERN.match(uri) if not match: raise ValueError(f"Invalid URI format: {uri}. Expected protocol://path format.") From 35e20774c09bd5c3c3b2c7b5a09187b03ecdc768 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 20 May 2025 21:05:44 -0400 Subject: [PATCH 045/114] Add documentation for config-based clients --- .../{features.mdx => advanced-features.mdx} | 0 docs/clients/client.mdx | 62 ++++++++++++++- docs/clients/transports.mdx | 63 ++++++++++++++- docs/docs.json | 4 +- docs/servers/composition.mdx | 4 + docs/servers/proxy.mdx | 58 ++++++++++++++ src/fastmcp/client/transports.py | 78 ++++++++++++++++++- 7 files changed, 264 insertions(+), 5 deletions(-) rename docs/clients/{features.mdx => advanced-features.mdx} (100%) diff --git a/docs/clients/features.mdx b/docs/clients/advanced-features.mdx similarity index 100% rename from docs/clients/features.mdx rename to docs/clients/advanced-features.mdx diff --git a/docs/clients/client.mdx b/docs/clients/client.mdx index 02900b373..fddc77b73 100644 --- a/docs/clients/client.mdx +++ b/docs/clients/client.mdx @@ -43,7 +43,8 @@ The following inference rules are used to determine the appropriate `ClientTrans * If it ends with `.js`: Creates a `NodeStdioTransport` to run the script using `node`. 4. **`AnyUrl` or `str` pointing to a URL that begins with `http://` or `https://`**: * Creates a `StreamableHttpTransport` -5. **Other**: Raises a `ValueError` if the type cannot be inferred. +5. **`MCPConfig` or dictionary matching MCPConfig schema**: Creates a client that connects to one or more MCP servers specified in the config. +6. **Other**: Raises a `ValueError` if the type cannot be inferred. ```python import asyncio @@ -76,6 +77,65 @@ print(client_stdio.transport) For more control over connection details (like headers for SSE, environment variables for Stdio), you can instantiate the specific `ClientTransport` class yourself and pass it to the `Client`. See the [Transports](/clients/transports) page for details. +### Multi-Server Clients + + + +FastMCP supports creating clients that connect to multiple MCP servers through a single client interface using a standard MCP configuration format (`MCPConfig`). This configuration approach makes it easy to connect to multiple specialized servers or create composable systems with a simple, declarative syntax. + + +The MCP configuration format follows an emerging standard and may evolve as the specification matures. FastMCP will strive to maintain compatibility with future versions, but be aware that field names or structure might change. + + +When you create a client with an `MCPConfig` containing multiple servers: + +1. FastMCP creates a composite client that internally mounts all servers using their config names as prefixes +2. Tools and resources from each server are accessible with appropriate prefixes in the format `servername_toolname` and `protocol://servername/resource/path` +3. You interact with this as a single unified client, with requests automatically routed to the appropriate server + +```python +from fastmcp import Client +from fastmcp.utilities.mcp_config import MCPConfig + +# Create a standard MCP configuration with multiple servers +config = { + "mcpServers": { + # A remote HTTP server + "weather": { + "url": "https://weather-api.example.com/mcp", + "transport": "streamable-http" + }, + # A local server running via stdio + "assistant": { + "command": "python", + "args": ["./my_assistant_server.py"], + "env": {"DEBUG": "true"} + } + } +} + +# Create a client that connects to both servers +client = Client(config) + +async def main(): + async with client: + # Access tools from different servers with prefixes + weather_data = await client.call_tool("weather_get_forecast", {"city": "London"}) + response = await client.call_tool("assistant_answer_question", {"question": "What's the capital of France?"}) + + # Access resources with prefixed URIs + weather_icons = await client.read_resource("weather://weather/icons/sunny") + templates = await client.read_resource("resource://assistant/templates/list") + + print(f"Weather: {weather_data}") + print(f"Assistant: {response}") + +if __name__ == "__main__": + asyncio.run(main()) +``` + +If your configuration has only a single server, FastMCP will create a direct client to that server without any prefixing. + ## Client Usage ### Connection Lifecycle diff --git a/docs/clients/transports.mdx b/docs/clients/transports.mdx index 4653c7233..da74a2619 100644 --- a/docs/clients/transports.mdx +++ b/docs/clients/transports.mdx @@ -317,4 +317,65 @@ async def main(): asyncio.run(main()) ``` -Communication happens through efficient in-memory queues, making it very fast and ideal for unit testing. \ No newline at end of file +Communication happens through efficient in-memory queues, making it very fast and ideal for unit testing. + +## Configuration-Based Transports + +### MCPConfig Transport + + + +- **Class:** `fastmcp.client.transports.MCPConfigTransport` +- **Inferred From:** An instance of `MCPConfig` or a dictionary matching the MCPConfig schema +- **Use Case:** Connecting to one or more MCP servers defined in a configuration object + +MCPConfig follows an emerging standard for MCP server configuration but is subject to change as the specification evolves. The standard supports both local servers (running via stdio) and remote servers (accessed via HTTP). + +```python +from fastmcp import Client +from fastmcp.utilities.mcp_config import MCPConfig + +# Configuration for multiple MCP servers (both local and remote) +config = { + "mcpServers": { + # Remote HTTP server + "weather": { + "url": "https://weather-api.example.com/mcp", + "transport": "streamable-http" + }, + # Local stdio server + "assistant": { + "command": "python", + "args": ["./assistant_server.py"], + "env": {"DEBUG": "true"} + }, + # Another remote server + "calendar": { + "url": "https://calendar-api.example.com/mcp", + "transport": "streamable-http" + } + } +} + +# Create a transport from the config (happens automatically with Client) +client = Client(config) + +async def main(): + async with client: + # Tools are accessible with server name prefixes + weather = await client.call_tool("weather_get_forecast", {"city": "London"}) + answer = await client.call_tool("assistant_answer_question", {"query": "What is MCP?"}) + events = await client.call_tool("calendar_list_events", {"date": "2023-06-01"}) + + # Resources use prefixed URI paths + icons = await client.read_resource("weather://weather/icons/sunny") + docs = await client.read_resource("resource://assistant/docs/mcp") + +asyncio.run(main()) +``` + +If your configuration has only a single server, the client will connect directly to that server without any prefixing. This makes it convenient to switch between single and multi-server configurations without changing your client code. + + +The MCPConfig format is an emerging standard for MCP server configuration and may change as the MCP ecosystem evolves. While FastMCP aims to maintain compatibility with future versions, be aware that field names or structure might change. + \ No newline at end of file diff --git a/docs/docs.json b/docs/docs.json index 27e4a506c..cc0699edf 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -67,8 +67,8 @@ "group": "Clients", "pages": [ "clients/client", - "clients/features", - "clients/transports" + "clients/transports", + "clients/advanced-features" ] }, { diff --git a/docs/servers/composition.mdx b/docs/servers/composition.mdx index b1308fa55..b4fe70fc1 100644 --- a/docs/servers/composition.mdx +++ b/docs/servers/composition.mdx @@ -35,6 +35,10 @@ The choice of importing or mounting depends on your use case and requirements. 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. + + +You can also create proxies from configuration dictionaries that follow the MCPConfig schema, which is useful for quickly connecting to one or more remote servers. See the [Proxy Servers documentation](/servers/proxy#configuration-based-proxies) for details on configuration-based proxying. Note that MCPConfig follows an emerging standard and its format may evolve over time. + ## 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. diff --git a/docs/servers/proxy.mdx b/docs/servers/proxy.mdx index f755b8ead..c35a4a474 100644 --- a/docs/servers/proxy.mdx +++ b/docs/servers/proxy.mdx @@ -104,6 +104,64 @@ proxy = FastMCP.as_proxy( # requests to original_server ``` +### Configuration-Based Proxies + + + +You can create a proxy directly from a configuration dictionary that follows the MCPConfig schema. This is useful for quickly setting up proxies to remote servers without manually configuring each connection detail. + +```python +from fastmcp import FastMCP + +# Create a proxy directly from a config dictionary +config = { + "mcpServers": { + "default": { # For single server configs, 'default' is commonly used + "url": "https://example.com/mcp", + "transport": "streamable-http" + } + } +} + +# Create a proxy to the configured server +proxy = FastMCP.as_proxy(config, name="Config-Based Proxy") + +# Run the proxy with stdio transport for local access +if __name__ == "__main__": + proxy.run() +``` + + +The MCPConfig format follows an emerging standard for MCP server configuration and may evolve as the specification matures. While FastMCP aims to maintain compatibility with future versions, be aware that field names or structure might change. + + +You can also use MCPConfig to create a proxy to multiple servers. When multiple servers are specified, they are automatically mounted with their config names as prefixes, providing a unified interface to all servers: + +```python +from fastmcp import FastMCP + +# Multi-server configuration +config = { + "mcpServers": { + "weather": { + "url": "https://weather-api.example.com/mcp", + "transport": "streamable-http" + }, + "calendar": { + "url": "https://calendar-api.example.com/mcp", + "transport": "streamable-http" + } + } +} + +# Create a proxy to multiple servers +composite_proxy = FastMCP.as_proxy(config, name="Composite Proxy") + +# Tools and resources are accessible with prefixes: +# - weather_get_forecast, calendar_add_event +# - weather://weather/icons/sunny, calendar://calendar/events/today +``` + ## `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. diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index f16724cf3..e2ae60816 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -474,7 +474,52 @@ class FastMCPTransport(ClientTransport): class MCPConfigTransport(ClientTransport): - """Transport for running MCPConfig.""" + """Transport for connecting to one or more MCP servers defined in an MCPConfig. + + This transport provides a unified interface to multiple MCP servers defined in an MCPConfig + object or dictionary matching the MCPConfig schema. It supports two key scenarios: + + 1. If the MCPConfig contains exactly one server, it creates a direct transport to that server. + 2. If the MCPConfig contains multiple servers, it creates a composite client by mounting + all servers on a single FastMCP instance, with each server's name used as its mounting prefix. + + In the multi-server case, tools are accessible with the prefix pattern `{server_name}_{tool_name}` + and resources with the pattern `protocol://{server_name}/path/to/resource`. + + This is particularly useful for creating clients that need to interact with multiple specialized + MCP servers through a single interface, simplifying client code. + + Examples: + ```python + from fastmcp import Client + from fastmcp.utilities.mcp_config import MCPConfig + + # Create a config with multiple servers + config = { + "mcpServers": { + "weather": { + "url": "https://weather-api.example.com/mcp", + "transport": "streamable-http" + }, + "calendar": { + "url": "https://calendar-api.example.com/mcp", + "transport": "streamable-http" + } + } + } + + # Create a client with the config + client = Client(config) + + async with client: + # Access tools with prefixes + weather = await client.call_tool("weather_get_forecast", {"city": "London"}) + events = await client.call_tool("calendar_list_events", {"date": "2023-06-01"}) + + # Access resources with prefixed URIs + icons = await client.read_resource("weather://weather/icons/sunny") + ``` + """ def __init__(self, config: MCPConfig | dict): from fastmcp.client.client import Client @@ -526,7 +571,38 @@ def infer_transport( argument, handling various input types and converting them to the appropriate ClientTransport subclass. + The function supports these input types: + - ClientTransport: Used directly without modification + - FastMCPServer: Creates an in-memory FastMCPTransport + - Path or str (file path): Creates PythonStdioTransport (.py) or NodeStdioTransport (.js) + - AnyUrl or str (URL): Creates StreamableHttpTransport (default) or SSETransport (for /sse endpoints) + - MCPConfig or dict: Creates MCPConfigTransport, potentially connecting to multiple servers + For HTTP URLs, they are assumed to be Streamable HTTP URLs unless they end in `/sse`. + + For MCPConfig with multiple servers, a composite client is created where each server + is mounted with its name as prefix. This allows accessing tools and resources from multiple + servers through a single unified client interface, using naming patterns like + `servername_toolname` for tools and `protocol://servername/path` for resources. + If the MCPConfig contains only one server, a direct connection is established without prefixing. + + Examples: + ```python + # Connect to a local Python script + transport = infer_transport("my_script.py") + + # Connect to a remote server via HTTP + transport = infer_transport("http://example.com/mcp") + + # Connect to multiple servers using MCPConfig + config = { + "mcpServers": { + "weather": {"url": "http://weather.example.com/mcp"}, + "calendar": {"url": "http://calendar.example.com/mcp"} + } + } + transport = infer_transport(config) + ``` """ from fastmcp.utilities.mcp_config import MCPConfig From b2dc2f0473b0565cf9b1bb06d3c42a61855c1969 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 20 May 2025 21:10:44 -0400 Subject: [PATCH 046/114] Clean up --- README.md | 23 +++++++++++++++++++++++ docs/clients/client.mdx | 1 - docs/clients/transports.mdx | 1 - src/fastmcp/utilities/exceptions.py | 1 - 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8dd3787fd..15356516e 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,29 @@ async def main(): # ... use the client ``` +FastMCP also supports connecting to multiple servers through a single unified client using the standard MCP configuration format: + +```python +from fastmcp import Client + +# Standard MCP configuration with multiple servers +config = { + "mcpServers": { + "weather": {"url": "https://weather-api.example.com/mcp"}, + "assistant": {"command": "python", "args": ["./assistant_server.py"]} + } +} + +# Create a client that connects to all servers +client = Client(config) + +async def main(): + async with client: + # Access tools and resources with server prefixes + forecast = await client.call_tool("weather_get_forecast", {"city": "London"}) + answer = await client.call_tool("assistant_answer_question", {"query": "What is MCP?"}) +``` + Learn more in the [**Client Documentation**](https://gofastmcp.com/clients/client) and [**Transports Documentation**](https://gofastmcp.com/clients/transports). ## Advanced Features diff --git a/docs/clients/client.mdx b/docs/clients/client.mdx index fddc77b73..422bac4cf 100644 --- a/docs/clients/client.mdx +++ b/docs/clients/client.mdx @@ -95,7 +95,6 @@ When you create a client with an `MCPConfig` containing multiple servers: ```python from fastmcp import Client -from fastmcp.utilities.mcp_config import MCPConfig # Create a standard MCP configuration with multiple servers config = { diff --git a/docs/clients/transports.mdx b/docs/clients/transports.mdx index da74a2619..958cafcae 100644 --- a/docs/clients/transports.mdx +++ b/docs/clients/transports.mdx @@ -333,7 +333,6 @@ MCPConfig follows an emerging standard for MCP server configuration but is subje ```python from fastmcp import Client -from fastmcp.utilities.mcp_config import MCPConfig # Configuration for multiple MCP servers (both local and remote) config = { diff --git a/src/fastmcp/utilities/exceptions.py b/src/fastmcp/utilities/exceptions.py index 8cbd4b7f5..e50dc57b0 100644 --- a/src/fastmcp/utilities/exceptions.py +++ b/src/fastmcp/utilities/exceptions.py @@ -18,7 +18,6 @@ def iter_exc(group: BaseExceptionGroup): def _exception_handler(group: BaseExceptionGroup): - print(list(iter_exc(group))) for leaf in iter_exc(group): if isinstance(leaf, httpx.ConnectTimeout): raise McpError( From 725f256a8f1c23b584135d5e8461fae9edb081ba Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 20 May 2025 21:13:15 -0400 Subject: [PATCH 047/114] Docs cleanup --- docs/clients/client.mdx | 20 ++++++++++++++++---- docs/servers/proxy.mdx | 4 ++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/docs/clients/client.mdx b/docs/clients/client.mdx index 422bac4cf..7c1455712 100644 --- a/docs/clients/client.mdx +++ b/docs/clients/client.mdx @@ -53,26 +53,38 @@ from fastmcp import Client, FastMCP # Example transports (more details in Transports page) server_instance = FastMCP(name="TestServer") # In-memory server http_url = "https://example.com/mcp" # HTTP server URL -ws_url = "ws://localhost:9000" # WebSocket server URL server_script = "my_mcp_server.py" # Path to a Python server file # Client automatically infers the transport type client_in_memory = Client(server_instance) client_http = Client(http_url) -client_ws = Client(ws_url) + client_stdio = Client(server_script) print(client_in_memory.transport) print(client_http.transport) -print(client_ws.transport) print(client_stdio.transport) # Expected Output (types may vary slightly based on environment): # # -# # ``` + +You can also initialize a client from an MCP configuration dictionary or `MCPConfig` file: + +```python +from fastmcp import Client + +config = { + "mcpServers": { + "local": {"command": "python", "args": ["local_server.py"]}, + "remote": {"url": "https://example.com/mcp"}, + } +} + +client_config = Client(config) +``` For more control over connection details (like headers for SSE, environment variables for Stdio), you can instantiate the specific `ClientTransport` class yourself and pass it to the `Client`. See the [Transports](/clients/transports) page for details. diff --git a/docs/servers/proxy.mdx b/docs/servers/proxy.mdx index c35a4a474..ef2899cd2 100644 --- a/docs/servers/proxy.mdx +++ b/docs/servers/proxy.mdx @@ -10,7 +10,7 @@ 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.as_proxy()` class method. -`as_proxy()` accepts either an existing `Client` or any argument that can be passed to a `Client` as its `transport` parameter—such as another `FastMCP` instance or a URL to a remote server. +`as_proxy()` accepts either an existing `Client` or any argument that can be passed to a `Client` as its `transport` parameter—such as another `FastMCP` instance, a URL to a remote server, or an MCP configuration dictionary. ## What is Proxying? @@ -46,7 +46,7 @@ from fastmcp import FastMCP # Provide the backend in any form accepted by Client proxy_server = FastMCP.as_proxy( - "backend_server.py", # Could also be a FastMCP instance or a remote URL + "backend_server.py", # Could also be a FastMCP instance, config dict, or a remote URL name="MyProxyServer" # Optional settings for the proxy ) From 85c2acf66de5ebb4ebdefa413546609c189a2d2b Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 21 May 2025 12:37:21 -0400 Subject: [PATCH 048/114] Make resource prefix format configurable --- docs/servers/composition.mdx | 49 +++++- src/fastmcp/server/server.py | 155 ++++++++++++++----- src/fastmcp/settings.py | 16 ++ tests/deprecated/test_resource_prefixes.py | 98 ++++++++++++ tests/server/test_resource_prefix_formats.py | 65 ++++++++ 5 files changed, 338 insertions(+), 45 deletions(-) create mode 100644 tests/deprecated/test_resource_prefixes.py create mode 100644 tests/server/test_resource_prefix_formats.py diff --git a/docs/servers/composition.mdx b/docs/servers/composition.mdx index b1308fa55..b17facead 100644 --- a/docs/servers/composition.mdx +++ b/docs/servers/composition.mdx @@ -177,6 +177,49 @@ remote_proxy = FastMCP.as_proxy(Client("http://example.com/mcp")) main_server.mount("remote", remote_proxy) ``` - -Some MCP clients (like Claude Desktop) might have restrictions on characters allowed in tool names. FastMCP uses standard naming conventions: tools and prompts are prefixed with `{prefix}_` (e.g., "weather_forecast"), and resources use the format `protocol://{prefix}/path` (e.g., "data://weather/forecast"). - \ No newline at end of file + + +## Resource Prefix Formats + +When mounting or importing servers, resource URIs are usually prefixed to avoid naming conflicts. FastMCP supports two different formats for resource prefixes: + +### Path Format (Default) + +In path format, prefixes are added to the path component of the URI: + +``` +resource://prefix/path/to/resource +``` + +This is the default format since FastMCP 2.4. This format is recommended because it avoids issues with URI protocol restrictions (like underscores not being allowed in protocol names). + +### Protocol Format (Legacy) + +In protocol format, prefixes are added as part of the protocol: + +``` +prefix+resource://path/to/resource +``` + +This was the default format in FastMCP before 2.4. While still supported, it's not recommended for new code as it can cause problems with prefix names that aren't valid in URI protocols. + +### Configuring the Prefix Format + +You can configure the prefix format globally: + +```python +from fastmcp import settings +settings.settings.resource_prefix_format = "protocol" # Switch to legacy format +``` + +Or per-server: + +```python +# Create a server that uses legacy protocol format +server = FastMCP("LegacyServer", resource_prefix_format="protocol") + +# Create a server that uses new path format +server = FastMCP("NewServer", resource_prefix_format="path") +``` + +When mounting or importing servers, the prefix format of the parent server is used. \ No newline at end of file diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 462b18476..567f4aa84 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -123,6 +123,7 @@ class FastMCP(Generic[LifespanResultT]): on_duplicate_tools: DuplicateBehavior | None = None, on_duplicate_resources: DuplicateBehavior | None = None, on_duplicate_prompts: DuplicateBehavior | None = None, + resource_prefix_format: Literal["protocol", "path"] | None = None, **settings: Any, ): if settings: @@ -137,6 +138,14 @@ class FastMCP(Generic[LifespanResultT]): ) self.settings = fastmcp.settings.ServerSettings(**settings) + self.resource_prefix_format: Literal["protocol", "path"] + if resource_prefix_format is None: + self.resource_prefix_format = ( + fastmcp.settings.settings.resource_prefix_format + ) + else: + self.resource_prefix_format = resource_prefix_format + self.tags: set[str] = tags or set() self.dependencies = dependencies self._cache = TimedCache( @@ -1109,11 +1118,11 @@ class FastMCP(Generic[LifespanResultT]): # Import resources and templates from the mounted server for key, resource in (await server.get_resources()).items(): - prefixed_key = add_resource_prefix(key, prefix) + prefixed_key = add_resource_prefix(key, prefix, self.resource_prefix_format) self._resource_manager.add_resource(resource, key=prefixed_key) for key, template in (await server.get_resource_templates()).items(): - prefixed_key = add_resource_prefix(key, prefix) + prefixed_key = add_resource_prefix(key, prefix, self.resource_prefix_format) self._resource_manager.add_template(template, key=prefixed_key) # Import prompts from the mounted server @@ -1258,14 +1267,18 @@ class MountedServer: async def get_resources(self) -> dict[str, Resource]: resources = await self.server.get_resources() return { - add_resource_prefix(key, self.prefix): resource + add_resource_prefix( + key, self.prefix, self.server.resource_prefix_format + ): resource for key, resource in resources.items() } async def get_resource_templates(self) -> dict[str, ResourceTemplate]: templates = await self.server.get_resource_templates() return { - add_resource_prefix(key, self.prefix): template + add_resource_prefix( + key, self.prefix, self.server.resource_prefix_format + ): template for key, template in templates.items() } @@ -1280,10 +1293,12 @@ class MountedServer: return key.removeprefix(f"{self.prefix}_") def match_resource(self, key: str) -> bool: - return has_resource_prefix(key, self.prefix) + return has_resource_prefix(key, self.prefix, self.server.resource_prefix_format) def strip_resource_prefix(self, key: str) -> str: - return remove_resource_prefix(key, self.prefix) + return remove_resource_prefix( + key, self.prefix, self.server.resource_prefix_format + ) def match_prompt(self, key: str) -> bool: return key.startswith(f"{self.prefix}_") @@ -1292,7 +1307,9 @@ class MountedServer: return key.removeprefix(f"{self.prefix}_") -def add_resource_prefix(uri: str, prefix: str) -> str: +def add_resource_prefix( + uri: str, prefix: str, prefix_format: Literal["protocol", "path"] | None = None +) -> str: """Add a prefix to a resource URI. Args: @@ -1304,9 +1321,11 @@ def add_resource_prefix(uri: str, prefix: str) -> str: Examples: >>> add_resource_prefix("resource://path/to/resource", "prefix") - "resource://prefix/path/to/resource" + "resource://prefix/path/to/resource" # with new style + >>> add_resource_prefix("resource://path/to/resource", "prefix") + "prefix+resource://path/to/resource" # with legacy style >>> add_resource_prefix("resource:///absolute/path", "prefix") - "resource://prefix//absolute/path" + "resource://prefix//absolute/path" # with new style Raises: ValueError: If the URI doesn't match the expected protocol://path format @@ -1314,32 +1333,50 @@ def add_resource_prefix(uri: str, prefix: str) -> str: if not prefix: return uri - # Split the URI into protocol and path - match = URI_PATTERN.match(uri) - if not match: - raise ValueError(f"Invalid URI format: {uri}. Expected protocol://path format.") + # Get the server settings to check for legacy format preference - protocol, path = match.groups() + if prefix_format is None: + prefix_format = fastmcp.settings.settings.resource_prefix_format - # Add the prefix to the path - return f"{protocol}{prefix}/{path}" + if prefix_format == "protocol": + # Legacy style: prefix+protocol://path + return f"{prefix}+{uri}" + elif prefix_format == "path": + # New style: protocol://prefix/path + # Split the URI into protocol and path + match = URI_PATTERN.match(uri) + if not match: + raise ValueError( + f"Invalid URI format: {uri}. Expected protocol://path format." + ) + + protocol, path = match.groups() + + # Add the prefix to the path + return f"{protocol}{prefix}/{path}" + else: + raise ValueError(f"Invalid prefix format: {prefix_format}") -def remove_resource_prefix(uri: str, prefix: str) -> str: +def remove_resource_prefix( + uri: str, prefix: str, prefix_format: Literal["protocol", "path"] | None = None +) -> str: """Remove a prefix from a resource URI. Args: uri: The resource URI with a prefix prefix: The prefix to remove - + prefix_format: The format of the prefix to remove Returns: The resource URI with the prefix removed Examples: >>> remove_resource_prefix("resource://prefix/path/to/resource", "prefix") - "resource://path/to/resource" + "resource://path/to/resource" # with new style + >>> remove_resource_prefix("prefix+resource://path/to/resource", "prefix") + "resource://path/to/resource" # with legacy style >>> remove_resource_prefix("resource://prefix//absolute/path", "prefix") - "resource:///absolute/path" + "resource:///absolute/path" # with new style Raises: ValueError: If the URI doesn't match the expected protocol://path format @@ -1347,24 +1384,41 @@ def remove_resource_prefix(uri: str, prefix: str) -> str: if not prefix: return uri - # Split the URI into protocol and path - match = URI_PATTERN.match(uri) - if not match: - raise ValueError(f"Invalid URI format: {uri}. Expected protocol://path format.") + if prefix_format is None: + prefix_format = fastmcp.settings.settings.resource_prefix_format - protocol, path = match.groups() - - # Check if the path starts with the prefix followed by a / - prefix_pattern = f"^{re.escape(prefix)}/(.*?)$" - path_match = re.match(prefix_pattern, path) - if not path_match: + if prefix_format == "protocol": + # Legacy style: prefix+protocol://path + legacy_prefix = f"{prefix}+" + if uri.startswith(legacy_prefix): + return uri[len(legacy_prefix) :] return uri + elif prefix_format == "path": + # New style: protocol://prefix/path + # Split the URI into protocol and path + match = URI_PATTERN.match(uri) + if not match: + raise ValueError( + f"Invalid URI format: {uri}. Expected protocol://path format." + ) - # Return the URI without the prefix - return f"{protocol}{path_match.group(1)}" + protocol, path = match.groups() + + # Check if the path starts with the prefix followed by a / + prefix_pattern = f"^{re.escape(prefix)}/(.*?)$" + path_match = re.match(prefix_pattern, path) + if not path_match: + return uri + + # Return the URI without the prefix + return f"{protocol}{path_match.group(1)}" + else: + raise ValueError(f"Invalid prefix format: {prefix_format}") -def has_resource_prefix(uri: str, prefix: str) -> bool: +def has_resource_prefix( + uri: str, prefix: str, prefix_format: Literal["protocol", "path"] | None = None +) -> bool: """Check if a resource URI has a specific prefix. Args: @@ -1376,7 +1430,9 @@ def has_resource_prefix(uri: str, prefix: str) -> bool: Examples: >>> has_resource_prefix("resource://prefix/path/to/resource", "prefix") - True + True # with new style + >>> has_resource_prefix("prefix+resource://path/to/resource", "prefix") + True # with legacy style >>> has_resource_prefix("resource://other/path/to/resource", "prefix") False @@ -1386,13 +1442,28 @@ def has_resource_prefix(uri: str, prefix: str) -> bool: if not prefix: return False - # Split the URI into protocol and path - match = URI_PATTERN.match(uri) - if not match: - raise ValueError(f"Invalid URI format: {uri}. Expected protocol://path format.") + # Get the server settings to check for legacy format preference - _, path = match.groups() + if prefix_format is None: + prefix_format = fastmcp.settings.settings.resource_prefix_format - # Check if the path starts with the prefix followed by a / - prefix_pattern = f"^{re.escape(prefix)}/" - return bool(re.match(prefix_pattern, path)) + if prefix_format == "protocol": + # Legacy style: prefix+protocol://path + legacy_prefix = f"{prefix}+" + return uri.startswith(legacy_prefix) + elif prefix_format == "path": + # New style: protocol://prefix/path + # Split the URI into protocol and path + match = URI_PATTERN.match(uri) + if not match: + raise ValueError( + f"Invalid URI format: {uri}. Expected protocol://path format." + ) + + _, path = match.groups() + + # Check if the path starts with the prefix followed by a / + prefix_pattern = f"^{re.escape(prefix)}/" + return bool(re.match(prefix_pattern, path)) + else: + raise ValueError(f"Invalid prefix format: {prefix_format}") diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index 34209f5b0..a5c78b3b6 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -29,6 +29,7 @@ class Settings(BaseSettings): test_mode: bool = False log_level: LOG_LEVEL = "INFO" + client_raise_first_exceptiongroup_error: Annotated[ bool, Field( @@ -44,6 +45,21 @@ class Settings(BaseSettings): ), ), ] = True + + resource_prefix_format: Annotated[ + Literal["protocol", "path"], + Field( + default="path", + description=inspect.cleandoc( + """ + When perfixing a resource URI, either use path formatting (resource://prefix/path) + or protocol formatting (prefix+resource://path). Protocol formatting was the default in FastMCP < 2.4; + path formatting is current default. + """ + ), + ), + ] = "path" + tool_attempt_parse_json_args: Annotated[ bool, Field( diff --git a/tests/deprecated/test_resource_prefixes.py b/tests/deprecated/test_resource_prefixes.py new file mode 100644 index 000000000..03eb5ac94 --- /dev/null +++ b/tests/deprecated/test_resource_prefixes.py @@ -0,0 +1,98 @@ +"""Tests for legacy resource prefix behavior.""" + +from fastmcp import Client, FastMCP +from fastmcp.server.server import ( + add_resource_prefix, + has_resource_prefix, + remove_resource_prefix, +) +from fastmcp.utilities.tests import temporary_settings + + +class TestLegacyResourcePrefixes: + """Test the legacy resource prefix behavior.""" + + def test_add_resource_prefix_legacy(self): + """Test that add_resource_prefix uses the legacy format when resource_prefix_format is 'protocol'.""" + with temporary_settings(resource_prefix_format="protocol"): + result = add_resource_prefix("resource://path/to/resource", "prefix") + assert result == "prefix+resource://path/to/resource" + + # Empty prefix should return the original URI + result = add_resource_prefix("resource://path/to/resource", "") + assert result == "resource://path/to/resource" + + def test_remove_resource_prefix_legacy(self): + """Test that remove_resource_prefix uses the legacy format when resource_prefix_format is 'protocol'.""" + with temporary_settings(resource_prefix_format="protocol"): + result = remove_resource_prefix( + "prefix+resource://path/to/resource", "prefix" + ) + assert result == "resource://path/to/resource" + + # URI without the prefix should be returned as is + result = remove_resource_prefix("resource://path/to/resource", "prefix") + assert result == "resource://path/to/resource" + + # Empty prefix should return the original URI + result = remove_resource_prefix("resource://path/to/resource", "") + assert result == "resource://path/to/resource" + + def test_has_resource_prefix_legacy(self): + """Test that has_resource_prefix uses the legacy format when resource_prefix_format is 'protocol'.""" + with temporary_settings(resource_prefix_format="protocol"): + result = has_resource_prefix("prefix+resource://path/to/resource", "prefix") + assert result is True + + result = has_resource_prefix("resource://path/to/resource", "prefix") + assert result is False + + # Empty prefix should always return False + result = has_resource_prefix("resource://path/to/resource", "") + assert result is False + + +async def test_mount_with_legacy_prefixes(): + """Test mounting a server with legacy resource prefixes.""" + with temporary_settings(resource_prefix_format="protocol"): + main_server = FastMCP("MainServer") + sub_server = FastMCP("SubServer") + + @sub_server.resource("resource://test") + def get_test(): + return "test content" + + # Mount the server with a prefix + main_server.mount("sub", sub_server) + + # Check that the resource is prefixed using the legacy format + resources = await main_server.get_resources() + + # In legacy format, the key would be "sub+resource://test" + assert "sub+resource://test" in resources + + # Test accessing the resource through client + async with Client(main_server) as client: + result = await client.read_resource("sub+resource://test") + # Different content types might be returned, but we just want to verify we got something + assert len(result) > 0 + + +async def test_import_server_with_legacy_prefixes(): + """Test importing a server with legacy resource prefixes.""" + with temporary_settings(resource_prefix_format="protocol"): + main_server = FastMCP("MainServer") + sub_server = FastMCP("SubServer") + + @sub_server.resource("resource://test") + def get_test(): + return "test content" + + # Import the server with a prefix + await main_server.import_server("sub", sub_server) + + # Check that the resource is prefixed using the legacy format + resources = main_server._resource_manager.get_resources() + + # In legacy format, the key would be "sub+resource://test" + assert "sub+resource://test" in resources diff --git a/tests/server/test_resource_prefix_formats.py b/tests/server/test_resource_prefix_formats.py new file mode 100644 index 000000000..b8273845d --- /dev/null +++ b/tests/server/test_resource_prefix_formats.py @@ -0,0 +1,65 @@ +"""Tests for different resource prefix formats in server mounting and importing.""" + +from fastmcp import FastMCP + + +async def test_resource_prefix_format_in_constructor(): + """Test that the resource_prefix_format parameter is respected in the constructor.""" + server_path = FastMCP("PathFormat", resource_prefix_format="path") + server_protocol = FastMCP("ProtocolFormat", resource_prefix_format="protocol") + + # Check that the format is stored correctly + assert server_path.resource_prefix_format == "path" + assert server_protocol.resource_prefix_format == "protocol" + + # Register resources + @server_path.resource("resource://test") + def get_test_path(): + return "test content" + + @server_protocol.resource("resource://test") + def get_test_protocol(): + return "test content" + + # Create mount servers + main_server_path = FastMCP("MainPath", resource_prefix_format="path") + main_server_protocol = FastMCP("MainProtocol", resource_prefix_format="protocol") + + # Mount the servers + main_server_path.mount("sub", server_path) + main_server_protocol.mount("sub", server_protocol) + + # Check that the resources are prefixed correctly + path_resources = await main_server_path.get_resources() + protocol_resources = await main_server_protocol.get_resources() + + # Path format should be resource://sub/test + assert "resource://sub/test" in path_resources + # Protocol format should be sub+resource://test + assert "sub+resource://test" in protocol_resources + + +async def test_resource_prefix_format_in_import_server(): + """Test that the resource_prefix_format parameter is respected in import_server.""" + server = FastMCP("TestServer") + + @server.resource("resource://test") + def get_test(): + return "test content" + + # Import with path format + main_server_path = FastMCP("MainPath", resource_prefix_format="path") + await main_server_path.import_server("sub", server) + + # Import with protocol format + main_server_protocol = FastMCP("MainProtocol", resource_prefix_format="protocol") + await main_server_protocol.import_server("sub", server) + + # Check that the resources are prefixed correctly + path_resources = main_server_path._resource_manager.get_resources() + protocol_resources = main_server_protocol._resource_manager.get_resources() + + # Path format should be resource://sub/test + assert "resource://sub/test" in path_resources + # Protocol format should be sub+resource://test + assert "sub+resource://test" in protocol_resources From eb68fb2a6da389cc7ddfaf020fc5e86104c9e602 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 21 May 2025 12:37:54 -0400 Subject: [PATCH 049/114] Update composition.mdx --- docs/servers/composition.mdx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/servers/composition.mdx b/docs/servers/composition.mdx index b17facead..605da718c 100644 --- a/docs/servers/composition.mdx +++ b/docs/servers/composition.mdx @@ -205,11 +205,17 @@ This was the default format in FastMCP before 2.4. While still supported, it's n ### Configuring the Prefix Format -You can configure the prefix format globally: +You can configure the prefix format globally in code: ```python from fastmcp import settings -settings.settings.resource_prefix_format = "protocol" # Switch to legacy format +settings.settings.resource_prefix_format = "protocol" +``` + +Or via environment variable: + +```bash +FASTMCP_RESOURCE_PREFIX_FORMAT=protocol ``` Or per-server: From 4c063bd71cb51138cf980b11877f0ae5c7e264fe Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 21 May 2025 12:41:45 -0400 Subject: [PATCH 050/114] Update improts --- docs/servers/composition.mdx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/servers/composition.mdx b/docs/servers/composition.mdx index 605da718c..26763e949 100644 --- a/docs/servers/composition.mdx +++ b/docs/servers/composition.mdx @@ -208,8 +208,8 @@ This was the default format in FastMCP before 2.4. While still supported, it's n You can configure the prefix format globally in code: ```python -from fastmcp import settings -settings.settings.resource_prefix_format = "protocol" +import fastmcp +fastmcp.settings.settings.resource_prefix_format = "protocol" ``` Or via environment variable: @@ -221,6 +221,8 @@ FASTMCP_RESOURCE_PREFIX_FORMAT=protocol Or per-server: ```python +from fastmcp import FastMCP + # Create a server that uses legacy protocol format server = FastMCP("LegacyServer", resource_prefix_format="protocol") From 55f854cc105d5aa314360a980fe222d9840fae23 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 21 May 2025 15:59:22 -0400 Subject: [PATCH 051/114] Add version bad for prefix formats --- docs/servers/composition.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/servers/composition.mdx b/docs/servers/composition.mdx index 20988dc43..dc584bced 100644 --- a/docs/servers/composition.mdx +++ b/docs/servers/composition.mdx @@ -185,6 +185,8 @@ main_server.mount("remote", remote_proxy) ## Resource Prefix Formats + + When mounting or importing servers, resource URIs are usually prefixed to avoid naming conflicts. FastMCP supports two different formats for resource prefixes: ### Path Format (Default) From 1afe73c13624a6e3325ac5a71c3ac615ee06d17a Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 21 May 2025 17:42:26 -0400 Subject: [PATCH 052/114] feat: support FastMCP v1 server transport --- docs/clients/client.mdx | 2 +- docs/clients/transports.mdx | 4 ++-- src/fastmcp/client/transports.py | 26 +++++++++++++++++--------- tests/client/test_client.py | 17 +++++++++++++++-- 4 files changed, 35 insertions(+), 14 deletions(-) diff --git a/docs/clients/client.mdx b/docs/clients/client.mdx index 7c1455712..70c47bfa9 100644 --- a/docs/clients/client.mdx +++ b/docs/clients/client.mdx @@ -37,7 +37,7 @@ Clients must be initialized with a `transport`. You can either provide an alread The following inference rules are used to determine the appropriate `ClientTransport` based on the input type: 1. **`ClientTransport` Instance**: If you provide an already instantiated transport object, it's used directly. -2. **`FastMCP` Instance**: Creates a `FastMCPTransport` for efficient in-memory communication (ideal for testing). +2. **`FastMCP` Instance**: Creates a `FastMCPTransport` for efficient in-memory communication (ideal for testing). This also works with a **FastMCP 1.0 server** created via `mcp.server.fastmcp.FastMCP`. 3. **`Path` or `str` pointing to an existing file**: * If it ends with `.py`: Creates a `PythonStdioTransport` to run the script using `python`. * If it ends with `.js`: Creates a `NodeStdioTransport` to run the script using `node`. diff --git a/docs/clients/transports.mdx b/docs/clients/transports.mdx index 958cafcae..b89f46884 100644 --- a/docs/clients/transports.mdx +++ b/docs/clients/transports.mdx @@ -290,8 +290,8 @@ asyncio.run(main()) ### FastMCP Transport - **Class:** `fastmcp.client.transports.FastMCPTransport` -- **Inferred From:** An instance of `fastmcp.server.FastMCP` -- **Use Case:** Connecting directly to a `FastMCP` server instance in the same Python process +- **Inferred From:** An instance of `fastmcp.server.FastMCP` or a **FastMCP 1.0 server** (`mcp.server.fastmcp.FastMCP`) +- **Use Case:** Connecting directly to a FastMCP server instance in the same Python process This is extremely useful for testing your FastMCP servers. diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index e2ae60816..7aacd5ad6 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -19,6 +19,7 @@ from mcp.client.sse import sse_client from mcp.client.stdio import stdio_client from mcp.client.streamable_http import streamablehttp_client from mcp.client.websocket import websocket_client +from mcp.server.fastmcp import FastMCP as FastMCP1Server from mcp.shared.memory import create_connected_server_and_client_session from pydantic import AnyUrl from typing_extensions import Unpack @@ -448,15 +449,21 @@ class NpxStdioTransport(StdioTransport): class FastMCPTransport(ClientTransport): - """ - Special transport for in-memory connections to an MCP server. + """In-memory transport for FastMCP servers. - This is particularly useful for testing or when client and server - are in the same process. + This transport connects directly to a FastMCP server instance in the same + Python process. It works with both FastMCP 2.x servers and FastMCP 1.0 + servers from the low-level MCP SDK. This is particularly useful for unit + tests or scenarios where client and server run in the same runtime. """ - def __init__(self, mcp: FastMCPServer): - self.server = mcp # Can be FastMCP or MCPServer + def __init__(self, mcp: FastMCPServer | FastMCP1Server): + """Initialize a FastMCPTransport from a FastMCP server instance.""" + + # Accept both FastMCP 2.x and FastMCP 1.0 servers. Both expose a + # ``_mcp_server`` attribute pointing to the underlying MCP server + # implementation, so we can treat them identically. + self.server = mcp @contextlib.asynccontextmanager async def connect_session( @@ -558,6 +565,7 @@ class MCPConfigTransport(ClientTransport): def infer_transport( transport: ClientTransport | FastMCPServer + | FastMCP1Server | AnyUrl | Path | MCPConfig @@ -573,7 +581,7 @@ def infer_transport( The function supports these input types: - ClientTransport: Used directly without modification - - FastMCPServer: Creates an in-memory FastMCPTransport + - FastMCPServer or FastMCP1Server: Creates an in-memory FastMCPTransport - Path or str (file path): Creates PythonStdioTransport (.py) or NodeStdioTransport (.js) - AnyUrl or str (URL): Creates StreamableHttpTransport (default) or SSETransport (for /sse endpoints) - MCPConfig or dict: Creates MCPConfigTransport, potentially connecting to multiple servers @@ -610,8 +618,8 @@ def infer_transport( if isinstance(transport, ClientTransport): return transport - # the transport is a FastMCP server - elif isinstance(transport, FastMCPServer): + # the transport is a FastMCP server (2.x or 1.0) + elif isinstance(transport, FastMCPServer | FastMCP1Server): inferred_transport = FastMCPTransport(mcp=transport) # the transport is a path to a script diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 62bddf0ab..0e97cb8d6 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -673,7 +673,7 @@ class TestInferTransport: assert transport.transport.command == "echo" assert transport.transport.args == ["hello"] - def test_infer_composite_client(config): + def test_infer_composite_client(self): config = { "mcpServers": { "local": { @@ -689,4 +689,17 @@ class TestInferTransport: transport = infer_transport(config) assert isinstance(transport, MCPConfigTransport) assert isinstance(transport.transport, FastMCPTransport) - assert len(transport.transport.server._mounted_servers) == 2 + assert len(cast(FastMCP, transport.transport.server)._mounted_servers) == 2 + + def test_infer_fastmcp_server(self, fastmcp_server): + """FastMCP server instances should infer to FastMCPTransport.""" + transport = infer_transport(fastmcp_server) + assert isinstance(transport, FastMCPTransport) + + def test_infer_fastmcp_v1_server(self): + """FastMCP 1.0 server instances should infer to FastMCPTransport.""" + from mcp.server.fastmcp import FastMCP as FastMCP1 + + server = FastMCP1() + transport = infer_transport(server) + assert isinstance(transport, FastMCPTransport) From 91228339985a7cbc6308649157c45c2e38706b76 Mon Sep 17 00:00:00 2001 From: davenpi Date: Wed, 21 May 2025 19:34:45 -0400 Subject: [PATCH 053/114] Expose model preferences in ctx.sample --- docs/servers/context.mdx | 7 +++--- src/fastmcp/server/context.py | 43 +++++++++++++++++++++++++++++++++++ tests/server/test_context.py | 29 +++++++++++++++++++++++ 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/docs/servers/context.mdx b/docs/servers/context.mdx index 3a84286e8..a0d21ad8e 100644 --- a/docs/servers/context.mdx +++ b/docs/servers/context.mdx @@ -228,8 +228,8 @@ async def analyze_sentiment(text: str, ctx: Context) -> dict: # Create a sampling prompt asking for sentiment analysis prompt = f"Analyze the sentiment of the following text as positive, negative, or neutral. Just output a single word - 'positive', 'negative', or 'neutral'. Text to analyze: {text}" - # Send the sampling request to the client's LLM - response = await ctx.sample(prompt) + # Send the sampling request to the clients LLM (provide a hint for the model you want to use) + response = await ctx.sample(prompt, model_preferences="claude-3-sonnet") # Process the LLM's response sentiment = response.text.strip().lower() @@ -247,11 +247,12 @@ async def analyze_sentiment(text: str, ctx: Context) -> dict: **Method signature:** -- **`ctx.sample(messages: str | list[str | SamplingMessage], system_prompt: str | None = None, temperature: float | None = None, max_tokens: int | None = None) -> TextContent | ImageContent`** +- **`ctx.sample(messages: str | list[str | SamplingMessage], system_prompt: str | None = None, temperature: float | None = None, max_tokens: int | None = None, model_preferences: ModelPreferences | str | list[str] | None = None) -> TextContent | ImageContent`** - `messages`: A string or list of strings/message objects to send to the LLM - `system_prompt`: Optional system prompt to guide the LLM's behavior - `temperature`: Optional sampling temperature (controls randomness) - `max_tokens`: Optional maximum number of tokens to generate (defaults to 512) + - `model_preferences`: Optional model selection preferences (e.g., a model hint string, list of hints, or a ModelPreferences object) - Returns the LLM's response as TextContent or ImageContent When providing a simple string, it's treated as a user message. For more complex scenarios, you can provide a list of messages with different roles. diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py index 4ecd992a7..1b2c8ad45 100644 --- a/src/fastmcp/server/context.py +++ b/src/fastmcp/server/context.py @@ -12,6 +12,8 @@ from mcp.shared.context import RequestContext from mcp.types import ( CreateMessageResult, ImageContent, + ModelHint, + ModelPreferences, Root, SamplingMessage, TextContent, @@ -200,6 +202,7 @@ class Context: system_prompt: str | None = None, temperature: float | None = None, max_tokens: int | None = None, + model_preferences: ModelPreferences | str | list[str] | None = None, ) -> TextContent | ImageContent: """ Send a sampling request to the client and await the response. @@ -231,6 +234,7 @@ class Context: system_prompt=system_prompt, temperature=temperature, max_tokens=max_tokens, + model_preferences=self._parse_model_preferences(model_preferences), ) return result.content @@ -248,3 +252,42 @@ class Context: ) return fastmcp.server.dependencies.get_http_request() + + def _parse_model_preferences(self, model_preferences) -> ModelPreferences | None: + """ + Validates and converts user input for model_preferences into a ModelPreferences object. + + Args: + model_preferences (ModelPreferences | str | list[str] | None): + The model preferences to use. Accepts: + - ModelPreferences (returns as-is) + - str (single model hint) + - list[str] (multiple model hints) + - None (no preferences) + + Returns: + ModelPreferences | None: The parsed ModelPreferences object, or None if not provided. + + Raises: + ValueError: If the input is not a supported type or contains invalid values. + """ + if model_preferences is None: + return None + if isinstance(model_preferences, ModelPreferences): + return model_preferences + if isinstance(model_preferences, str): + # Single model hint + return ModelPreferences(hints=[ModelHint(name=model_preferences)]) + if isinstance(model_preferences, list): + # List of model hints (strings) + if not all(isinstance(h, str) for h in model_preferences): + raise ValueError( + "All elements of model_preferences list must be" + " strings (model name hints)." + ) + return ModelPreferences( + hints=[ModelHint(name=h) for h in model_preferences] + ) + raise ValueError( + "model_preferences must be one of: ModelPreferences, str, list[str], or None." + ) diff --git a/tests/server/test_context.py b/tests/server/test_context.py index 4243ab4f9..a41b8b6e3 100644 --- a/tests/server/test_context.py +++ b/tests/server/test_context.py @@ -2,9 +2,11 @@ import warnings from unittest.mock import MagicMock, patch import pytest +from mcp.types import ModelPreferences from starlette.requests import Request from fastmcp.server.context import Context +from fastmcp.server.server import FastMCP class TestContextDeprecations: @@ -57,3 +59,30 @@ class TestContextDeprecations: assert "https://gofastmcp.com/patterns/http-requests" in str( warning.message ) + + +@pytest.fixture +def context(): + return Context(fastmcp=FastMCP()) + + +class TestParseModelPreferences: + def test_parse_model_preferences_string(self, context): + mp = context._parse_model_preferences("claude-3-sonnet") + assert isinstance(mp, ModelPreferences) + assert mp.hints is not None + assert mp.hints[0].name == "claude-3-sonnet" + + def test_parse_model_preferences_list(self, context): + mp = context._parse_model_preferences(["claude-3-sonnet", "claude"]) + assert isinstance(mp, ModelPreferences) + assert mp.hints is not None + assert [h.name for h in mp.hints] == ["claude-3-sonnet", "claude"] + + def test_parse_model_preferences_object(self, context): + obj = ModelPreferences(hints=[]) + assert context._parse_model_preferences(obj) is obj + + def test_parse_model_preferences_invalid_type(self, context): + with pytest.raises(ValueError): + context._parse_model_preferences(123) From 94f981ff865c2baa17783b4277b4f16961a0a2af Mon Sep 17 00:00:00 2001 From: Ian Davenport <49379192+davenpi@users.noreply.github.com> Date: Wed, 21 May 2025 19:45:59 -0400 Subject: [PATCH 054/114] Fix typo in docs. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/servers/context.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/servers/context.mdx b/docs/servers/context.mdx index a0d21ad8e..555a0c105 100644 --- a/docs/servers/context.mdx +++ b/docs/servers/context.mdx @@ -228,7 +228,7 @@ async def analyze_sentiment(text: str, ctx: Context) -> dict: # Create a sampling prompt asking for sentiment analysis prompt = f"Analyze the sentiment of the following text as positive, negative, or neutral. Just output a single word - 'positive', 'negative', or 'neutral'. Text to analyze: {text}" - # Send the sampling request to the clients LLM (provide a hint for the model you want to use) + # Send the sampling request to the client's LLM (provide a hint for the model you want to use) response = await ctx.sample(prompt, model_preferences="claude-3-sonnet") # Process the LLM's response From 53b067b4e7236c534f7a831f7933bfc689b95430 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 22 May 2025 09:08:16 -0400 Subject: [PATCH 055/114] Make error masking configurable --- docs/servers/resources.mdx | 21 ++++-- docs/servers/tools.mdx | 28 +++++--- src/fastmcp/client/client.py | 7 +- src/fastmcp/prompts/prompt.py | 5 +- src/fastmcp/prompts/prompt_manager.py | 28 ++++++-- src/fastmcp/resources/resource_manager.py | 36 +++++++++-- src/fastmcp/server/server.py | 79 +++++++++++++++-------- src/fastmcp/settings.py | 17 +++++ src/fastmcp/tools/tool_manager.py | 11 +++- tests/client/test_client.py | 61 ++++++++++++++++- tests/contrib/test_bulk_tool_caller.py | 4 +- tests/prompts/test_prompt_manager.py | 4 +- tests/resources/test_resource_manager.py | 73 +++++++++++---------- tests/tools/test_tool_manager.py | 44 +++++++++++-- 14 files changed, 317 insertions(+), 101 deletions(-) diff --git a/docs/servers/resources.mdx b/docs/servers/resources.mdx index 72d736d31..92838ebce 100644 --- a/docs/servers/resources.mdx +++ b/docs/servers/resources.mdx @@ -408,12 +408,20 @@ Templates provide a powerful way to expose parameterized data access points foll ## Error Handling - + If your resource function encounters an error, you can raise a standard Python exception (`ValueError`, `TypeError`, `FileNotFoundError`, custom exceptions, etc.) or a FastMCP `ResourceError`. -For security reasons, most exceptions are wrapped in a generic `ResourceError` before being sent to the client, with internal error details masked. However, if you raise a `ResourceError` directly, its contents **are** included in the response. This allows you to provide informative error messages to the client on an opt-in basis. +By default, all exceptions (including their details) are logged and converted into an MCP error response to be sent back to the client LLM. This helps the LLM understand failures and react appropriately. +If you want to mask internal error details for security reasons, you can: + +1. Use the `mask_error_details=True` parameter when creating your `FastMCP` instance: +```python +mcp = FastMCP(name="SecureServer", mask_error_details=True) +``` + +2. Or use `ResourceError` to explicitly control what error information is sent to clients: ```python from fastmcp import FastMCP from fastmcp.exceptions import ResourceError @@ -423,13 +431,14 @@ mcp = FastMCP(name="DataServer") @mcp.resource("resource://safe-error") def fail_with_details() -> str: """This resource provides detailed error information.""" - # ResourceError contents are sent back to clients + # ResourceError contents are always sent back to clients, + # regardless of mask_error_details setting raise ResourceError("Unable to retrieve data: file not found") @mcp.resource("resource://masked-error") def fail_with_masked_details() -> str: - """This resource masks internal error details.""" - # Other exceptions are converted to ResourceError with generic message + """This resource masks internal error details when mask_error_details=True.""" + # This message would be masked if mask_error_details=True raise ValueError("Sensitive internal file path: /etc/secrets.conf") @mcp.resource("data://{id}") @@ -442,7 +451,7 @@ def get_data_by_id(id: str) -> dict: return {"id": id, "value": "data"} ``` -This error handling pattern applies to both regular resources and resource templates. +When `mask_error_details=True`, only error messages from `ResourceError` will include details, other exceptions will be converted to a generic message. ## Server Behavior diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx index 3a850d9cc..879ceab02 100644 --- a/docs/servers/tools.mdx +++ b/docs/servers/tools.mdx @@ -248,13 +248,21 @@ def do_nothing() -> None: ### Error Handling - + If your tool encounters an error, you can raise a standard Python exception (`ValueError`, `TypeError`, `FileNotFoundError`, custom exceptions, etc.) or a FastMCP `ToolError`. -In all cases, the exception is logged and converted into an MCP error response to be sent back to the client LLM. For security reasons, the error message is **not** included in the response by default. However, if you raise a `ToolError`, the contents of the exception **are** included in the response. This allows you to provide informative error messages to the client LLM on an opt-in basis, which can help the LLM understand failures and react appropriately. +By default, all exceptions (including their details) are logged and converted into an MCP error response to be sent back to the client LLM. This helps the LLM understand failures and react appropriately. -```python {2, 10, 14} +If you want to mask internal error details for security reasons, you can: + +1. Use the `mask_error_details=True` parameter when creating your `FastMCP` instance: +```python +mcp = FastMCP(name="SecureServer", mask_error_details=True) +``` + +2. Or use `ToolError` to explicitly control what error information is sent to clients: +```python from fastmcp import FastMCP from fastmcp.exceptions import ToolError @@ -262,16 +270,20 @@ from fastmcp.exceptions import ToolError def divide(a: float, b: float) -> float: """Divide a by b.""" - # Python exceptions raise errors but the contents are not sent to clients + if b == 0: + # Error messages from ToolError are always sent to clients, + # regardless of mask_error_details setting + raise ToolError("Division by zero is not allowed.") + + # If mask_error_details=True, this message would be masked if not isinstance(a, (int, float)) or not isinstance(b, (int, float)): raise TypeError("Both arguments must be numbers.") - - if b == 0: - # ToolError contents are sent back to clients - raise ToolError("Division by zero is not allowed.") + return a / b ``` +When `mask_error_details=True`, only error messages from `ToolError` will include details, other exceptions will be converted to a generic message. + ### Annotations diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 19552ea35..c5446702c 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -322,7 +322,12 @@ class Client: RuntimeError: If called while the client is not connected. """ if isinstance(uri, str): - uri = AnyUrl(uri) # Ensure AnyUrl + try: + uri = AnyUrl(uri) # Ensure AnyUrl + except Exception as e: + raise ValueError( + f"Provided resource URI is invalid: {str(uri)!r}" + ) from e result = await self.read_resource_mcp(uri) return result.contents diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py index 85eb3bc9d..26698f5d6 100644 --- a/src/fastmcp/prompts/prompt.py +++ b/src/fastmcp/prompts/prompt.py @@ -12,6 +12,7 @@ from mcp.types import Prompt as MCPPrompt from mcp.types import PromptArgument as MCPPromptArgument from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call +from fastmcp.exceptions import PromptError from fastmcp.server.dependencies import get_context from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.logging import get_logger @@ -199,12 +200,12 @@ class Prompt(BaseModel): ) ) except Exception: - raise ValueError("Could not convert prompt result to message.") + raise PromptError("Could not convert prompt result to message.") return messages except Exception as e: logger.exception(f"Error rendering prompt {self.name}: {e}") - raise ValueError(f"Error rendering prompt {self.name}.") + raise PromptError(f"Error rendering prompt {self.name}.") def __eq__(self, other: object) -> bool: if not isinstance(other, Prompt): diff --git a/src/fastmcp/prompts/prompt_manager.py b/src/fastmcp/prompts/prompt_manager.py index 8102cd364..bfd96b573 100644 --- a/src/fastmcp/prompts/prompt_manager.py +++ b/src/fastmcp/prompts/prompt_manager.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any from mcp import GetPromptResult -from fastmcp.exceptions import NotFoundError +from fastmcp.exceptions import NotFoundError, PromptError from fastmcp.prompts.prompt import Prompt, PromptResult from fastmcp.settings import DuplicateBehavior from fastmcp.utilities.logging import get_logger @@ -21,8 +21,13 @@ logger = get_logger(__name__) class PromptManager: """Manages FastMCP prompts.""" - def __init__(self, duplicate_behavior: DuplicateBehavior | None = None): + def __init__( + self, + duplicate_behavior: DuplicateBehavior | None = None, + mask_error_details: bool = False, + ): self._prompts: dict[str, Prompt] = {} + self.mask_error_details = mask_error_details # Default to "warn" if None is provided if duplicate_behavior is None: @@ -85,9 +90,24 @@ class PromptManager: if not prompt: raise NotFoundError(f"Unknown prompt: {name}") - messages = await prompt.render(arguments) + try: + messages = await prompt.render(arguments) + return GetPromptResult(description=prompt.description, messages=messages) - return GetPromptResult(description=prompt.description, messages=messages) + # Pass through PromptErrors as-is + except PromptError as e: + logger.exception(f"Error rendering prompt {name!r}: {e}") + raise e + + # Handle other exceptions + except Exception as e: + logger.exception(f"Error rendering prompt {name!r}: {e}") + if self.mask_error_details: + # Mask internal details + raise PromptError(f"Error rendering prompt {name!r}") + else: + # Include original error details + raise PromptError(f"Error rendering prompt {name!r}: {e}") def has_prompt(self, key: str) -> bool: """Check if a prompt exists.""" diff --git a/src/fastmcp/resources/resource_manager.py b/src/fastmcp/resources/resource_manager.py index 9d8a20d8e..c3b74e5a4 100644 --- a/src/fastmcp/resources/resource_manager.py +++ b/src/fastmcp/resources/resource_manager.py @@ -22,9 +22,22 @@ logger = get_logger(__name__) class ResourceManager: """Manages FastMCP resources.""" - def __init__(self, duplicate_behavior: DuplicateBehavior | None = None): + def __init__( + self, + duplicate_behavior: DuplicateBehavior | None = None, + mask_error_details: bool = False, + ): + """Initialize the ResourceManager. + + Args: + duplicate_behavior: How to handle duplicate resources + (warn, error, replace, ignore) + mask_error_details: Whether to mask error details from exceptions + other than ResourceError + """ self._resources: dict[str, Resource] = {} self._templates: dict[str, ResourceTemplate] = {} + self.mask_error_details = mask_error_details # Default to "warn" if None is provided if duplicate_behavior is None: @@ -35,7 +48,6 @@ class ResourceManager: f"Invalid duplicate_behavior: {duplicate_behavior}. " f"Must be one of: {', '.join(DuplicateBehavior.__args__)}" ) - self.duplicate_behavior = duplicate_behavior def add_resource_or_template_from_fn( @@ -244,12 +256,21 @@ class ResourceManager: uri_str, params=params, ) + # Pass through ResourceErrors as-is except ResourceError as e: logger.error(f"Error creating resource from template: {e}") raise e + # Handle other exceptions except Exception as e: logger.error(f"Error creating resource from template: {e}") - raise ValueError(f"Error creating resource from template: {e}") + if self.mask_error_details: + # Mask internal details + raise ValueError("Error creating resource from template") from e + else: + # Include original error details + raise ValueError( + f"Error creating resource from template: {e}" + ) from e raise NotFoundError(f"Unknown resource: {uri_str}") @@ -265,10 +286,15 @@ class ResourceManager: logger.error(f"Error reading resource {uri!r}: {e}") raise e - # raise other exceptions as ResourceErrors without revealing internal details + # Handle other exceptions except Exception as e: logger.error(f"Error reading resource {uri!r}: {e}") - raise ResourceError(f"Error reading resource {uri!r}") from e + if self.mask_error_details: + # Mask internal details + raise ResourceError(f"Error reading resource {uri!r}") from e + else: + # Include original error details + raise ResourceError(f"Error reading resource {uri!r}: {e}") from e def get_resources(self) -> dict[str, Resource]: """Get all registered resources, keyed by URI.""" diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index c3f33fe81..50036ef74 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -125,6 +125,7 @@ class FastMCP(Generic[LifespanResultT]): on_duplicate_resources: DuplicateBehavior | None = None, on_duplicate_prompts: DuplicateBehavior | None = None, resource_prefix_format: Literal["protocol", "path"] | None = None, + mask_error_details: bool | None = None, **settings: Any, ): if settings: @@ -139,6 +140,10 @@ class FastMCP(Generic[LifespanResultT]): ) self.settings = fastmcp.settings.ServerSettings(**settings) + # If mask_error_details is provided, override the settings value + if mask_error_details is not None: + self.settings.mask_error_details = mask_error_details + self.resource_prefix_format: Literal["protocol", "path"] if resource_prefix_format is None: self.resource_prefix_format = ( @@ -157,11 +162,16 @@ class FastMCP(Generic[LifespanResultT]): self._tool_manager = ToolManager( duplicate_behavior=on_duplicate_tools, serializer=tool_serializer, + mask_error_details=self.settings.mask_error_details, ) self._resource_manager = ResourceManager( - duplicate_behavior=on_duplicate_resources + duplicate_behavior=on_duplicate_resources, + mask_error_details=self.settings.mask_error_details, + ) + self._prompt_manager = PromptManager( + duplicate_behavior=on_duplicate_prompts, + mask_error_details=self.settings.mask_error_details, ) - self._prompt_manager = PromptManager(duplicate_behavior=on_duplicate_prompts) if lifespan is None: self._has_lifespan = False @@ -377,21 +387,30 @@ class FastMCP(Generic[LifespanResultT]): async def _mcp_call_tool( self, key: str, arguments: dict[str, Any] ) -> list[TextContent | ImageContent | EmbeddedResource]: - """Call a tool by name with arguments.""" + """Handle MCP 'callTool' requests. + Args: + key: The name of the tool to call + arguments: Arguments to pass to the tool + + Returns: + List of MCP Content objects containing the tool results + """ + logger.debug("Call tool: %s with %s", key, arguments) + + # Create and use context for the entire call with fastmcp.server.context.Context(fastmcp=self): + # Get tool, checking first from our tools, then from the mounted servers if self._tool_manager.has_tool(key): - result = await self._tool_manager.call_tool(key, arguments) + return await self._tool_manager.call_tool(key, arguments) - else: - for server in self._mounted_servers.values(): - if server.match_tool(key): - 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}") - return result + # Check mounted servers to see if they have the tool + for server in self._mounted_servers.values(): + if server.match_tool(key): + tool_key = server.strip_tool_prefix(key) + return await server.server._mcp_call_tool(tool_key, arguments) + + raise NotFoundError(f"Unknown tool: {key}") async def _mcp_read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]: """ @@ -419,24 +438,30 @@ class FastMCP(Generic[LifespanResultT]): 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. + """Handle MCP 'getPrompt' requests. + Args: + name: The name of the prompt to render + arguments: Arguments to pass to the prompt + + Returns: + GetPromptResult containing the rendered prompt messages """ + logger.debug("Get prompt: %s with %s", name, arguments) + + # Create and use context for the entire call with fastmcp.server.context.Context(fastmcp=self): + # Get prompt, checking first from our prompts, then from the mounted servers if self._prompt_manager.has_prompt(name): - prompt_result = await self._prompt_manager.render_prompt( - name, arguments=arguments or {} - ) - return prompt_result - else: - for server in self._mounted_servers.values(): - if server.match_prompt(name): - new_key = server.strip_prompt_prefix(name) - return await server.server._mcp_get_prompt(new_key, arguments) - else: - raise NotFoundError(f"Unknown prompt: {name}") + return await self._prompt_manager.render_prompt(name, arguments) + + # Check mounted servers to see if they have the prompt + for server in self._mounted_servers.values(): + if server.match_prompt(name): + prompt_name = server.strip_prompt_prefix(name) + return await server.server._mcp_get_prompt(prompt_name, arguments) + + raise NotFoundError(f"Unknown prompt: {name}") def add_tool( self, diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index a5c78b3b6..c1dbf5447 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -124,6 +124,23 @@ class ServerSettings(BaseSettings): # prompt settings on_duplicate_prompts: DuplicateBehavior = "warn" + # error handling + mask_error_details: Annotated[ + bool, + Field( + default=False, + description=inspect.cleandoc( + """ + If True, error details from user-supplied functions (tool, resource, prompt) + will be masked before being sent to clients. Only error messages from explicitly + raised ToolError, ResourceError, or PromptError will be included in responses. + If False (default), all error details will be included in responses, but prefixed + with appropriate context. + """ + ), + ), + ] = False + dependencies: Annotated[ list[str], Field( diff --git a/src/fastmcp/tools/tool_manager.py b/src/fastmcp/tools/tool_manager.py index 848f07139..c511b18fa 100644 --- a/src/fastmcp/tools/tool_manager.py +++ b/src/fastmcp/tools/tool_manager.py @@ -23,9 +23,11 @@ class ToolManager: self, duplicate_behavior: DuplicateBehavior | None = None, serializer: Callable[[Any], str] | None = None, + mask_error_details: bool = False, ): self._tools: dict[str, Tool] = {} self._serializer = serializer + self.mask_error_details = mask_error_details # Default to "warn" if None is provided if duplicate_behavior is None: @@ -124,7 +126,12 @@ class ToolManager: logger.exception(f"Error calling tool {key!r}: {e}") raise e - # raise other exceptions as ToolErrors without revealing internal details + # Handle other exceptions except Exception as e: logger.exception(f"Error calling tool {key!r}: {e}") - raise ToolError(f"Error calling tool {key!r}") from e + if self.mask_error_details: + # Mask internal details + raise ToolError(f"Error calling tool {key!r}") from e + else: + # Include original error details + raise ToolError(f"Error calling tool {key!r}: {e}") from e diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 62bddf0ab..c4713c326 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -219,6 +219,13 @@ async def test_get_prompt_mcp(fastmcp_server): assert result.description == "Example greeting prompt." +async def test_read_resource_invalid_uri(fastmcp_server): + """Test reading a resource with an invalid URI.""" + client = Client(transport=FastMCPTransport(fastmcp_server)) + with pytest.raises(ValueError, match="Provided resource URI is invalid"): + await client.read_resource("invalid_uri") + + async def test_read_resource(fastmcp_server): """Test reading a resource with InMemoryClient.""" client = Client(transport=FastMCPTransport(fastmcp_server)) @@ -457,7 +464,7 @@ async def test_tagged_template_functionality(tagged_resources_server): class TestErrorHandling: - async def test_general_tool_exceptions_are_masked(self): + async def test_general_tool_exceptions_are_not_masked_by_default(self): mcp = FastMCP("TestServer") @mcp.tool() @@ -466,6 +473,22 @@ class TestErrorHandling: client = Client(transport=FastMCPTransport(mcp)) + async with client: + result = await client.call_tool_mcp("error_tool", {}) + assert result.isError + assert isinstance(result.content[0], TextContent) + assert "test error" in result.content[0].text + assert "abc" in result.content[0].text + + async def test_general_tool_exceptions_are_masked_when_enabled(self): + mcp = FastMCP("TestServer", mask_error_details=True) + + @mcp.tool() + def error_tool(): + raise ValueError("This is a test error (abc)") + + client = Client(transport=FastMCPTransport(mcp)) + async with client: result = await client.call_tool_mcp("error_tool", {}) assert result.isError @@ -489,7 +512,7 @@ class TestErrorHandling: assert "test error" in result.content[0].text assert "abc" in result.content[0].text - async def test_general_resource_exceptions_are_masked(self): + async def test_general_resource_exceptions_are_not_masked_by_default(self): mcp = FastMCP("TestServer") @mcp.resource(uri="exception://resource") @@ -498,6 +521,22 @@ class TestErrorHandling: client = Client(transport=FastMCPTransport(mcp)) + async with client: + with pytest.raises(Exception) as excinfo: + await client.read_resource(AnyUrl("exception://resource")) + assert "Error reading resource" in str(excinfo.value) + assert "sensitive" in str(excinfo.value) + assert "internal error" in str(excinfo.value) + + async def test_general_resource_exceptions_are_masked_when_enabled(self): + mcp = FastMCP("TestServer", mask_error_details=True) + + @mcp.resource(uri="exception://resource") + async def exception_resource(): + raise ValueError("This is an internal error (sensitive)") + + client = Client(transport=FastMCPTransport(mcp)) + async with client: with pytest.raises(Exception) as excinfo: await client.read_resource(AnyUrl("exception://resource")) @@ -519,7 +558,7 @@ class TestErrorHandling: await client.read_resource(AnyUrl("error://resource")) assert "This is a resource error (xyz)" in str(excinfo.value) - async def test_general_template_exceptions_are_masked(self): + async def test_general_template_exceptions_are_not_masked_by_default(self): mcp = FastMCP("TestServer") @mcp.resource(uri="exception://resource/{id}") @@ -528,6 +567,22 @@ class TestErrorHandling: client = Client(transport=FastMCPTransport(mcp)) + async with client: + with pytest.raises(Exception) as excinfo: + await client.read_resource(AnyUrl("exception://resource/123")) + assert "Error reading resource" in str(excinfo.value) + assert "sensitive" in str(excinfo.value) + assert "internal error" in str(excinfo.value) + + async def test_general_template_exceptions_are_masked_when_enabled(self): + mcp = FastMCP("TestServer", mask_error_details=True) + + @mcp.resource(uri="exception://resource/{id}") + async def exception_resource(id: str): + raise ValueError("This is an internal error (sensitive)") + + client = Client(transport=FastMCPTransport(mcp)) + async with client: with pytest.raises(Exception) as excinfo: await client.read_resource(AnyUrl("exception://resource/123")) diff --git a/tests/contrib/test_bulk_tool_caller.py b/tests/contrib/test_bulk_tool_caller.py index b6f86c927..9b74d87bb 100644 --- a/tests/contrib/test_bulk_tool_caller.py +++ b/tests/contrib/test_bulk_tool_caller.py @@ -27,7 +27,9 @@ async def error_tool(arg1: str) -> dict[str, Any]: def error_tool_result_factory(arg1: str) -> CallToolRequestResult: """Generates the expected error result for error_tool.""" # Mimic the error message format generated by BulkToolCaller when catching ToolException - formatted_error_text = "Error calling tool 'error_tool'" + formatted_error_text = ( + "Error calling tool 'error_tool': Error in tool with arg1: " + arg1 + ) return CallToolRequestResult( isError=True, content=[TextContent(text=formatted_error_text, type="text")], diff --git a/tests/prompts/test_prompt_manager.py b/tests/prompts/test_prompt_manager.py index c887fdca5..d44dde0bf 100644 --- a/tests/prompts/test_prompt_manager.py +++ b/tests/prompts/test_prompt_manager.py @@ -3,7 +3,7 @@ from typing import Annotated import pytest from fastmcp import Context -from fastmcp.exceptions import NotFoundError +from fastmcp.exceptions import NotFoundError, PromptError from fastmcp.prompts import Prompt from fastmcp.prompts.prompt import PromptMessage, TextContent from fastmcp.prompts.prompt_manager import PromptManager @@ -192,7 +192,7 @@ class TestPromptManager: manager = PromptManager() prompt = Prompt.from_function(fn) manager.add_prompt(prompt) - with pytest.raises(ValueError, match="Missing required arguments"): + with pytest.raises(PromptError, match="Missing required arguments"): await manager.render_prompt("fn") async def test_prompt_with_varargs_not_allowed(self): diff --git a/tests/resources/test_resource_manager.py b/tests/resources/test_resource_manager.py index 006911c47..ad3dba908 100644 --- a/tests/resources/test_resource_manager.py +++ b/tests/resources/test_resource_manager.py @@ -563,28 +563,6 @@ class TestResourceErrorHandling: with pytest.raises(ResourceError, match="Specific resource error"): await manager.read_resource("error://resource") - async def test_exception_converted_to_resource_error(self): - """Test that other exceptions are converted to ResourceError.""" - manager = ResourceManager() - - async def buggy_resource(): - """Resource that raises a ValueError.""" - raise ValueError("Internal error details") - - resource = FunctionResource( - uri=AnyUrl("buggy://resource"), - name="buggy_resource", - fn=buggy_resource, - ) - manager.add_resource(resource) - - with pytest.raises(ResourceError) as excinfo: - await manager.read_resource("buggy://resource") - - # Exception message should contain the resource URI but not the internal details - assert "Error reading resource 'buggy://resource'" in str(excinfo.value) - assert "Internal error details" not in str(excinfo.value) - async def test_template_resource_error_passthrough(self): """Test that ResourceErrors from template-generated resources are passed through.""" manager = ResourceManager() @@ -606,21 +584,46 @@ class TestResourceErrorHandling: # The original error message should be included in the ValueError assert "Template error with param test" in str(excinfo.value) - async def test_template_exception_converted_to_resource_error(self): - """Test that other exceptions from template-generated resources are converted.""" + async def test_exception_converted_to_resource_error_with_details(self): + """Test that other exceptions are converted to ResourceError with details by default.""" manager = ResourceManager() - def buggy_template(param: str): - """Template that raises a ValueError.""" - raise ValueError(f"Internal template error with {param}") + async def buggy_resource(): + """Resource that raises a ValueError.""" + raise ValueError("Internal error details") - template = ResourceTemplate.from_function( - fn=buggy_template, - uri_template="buggy://{param}", - name="buggy_template", + resource = FunctionResource( + uri=AnyUrl("buggy://resource"), + name="buggy_resource", + fn=buggy_resource, ) - manager.add_template(template) + manager.add_resource(resource) - # First, the template creation will fail with ValueError - with pytest.raises(ResourceError, match="Error reading resource"): - await manager.read_resource("buggy://test") + with pytest.raises(ResourceError) as excinfo: + await manager.read_resource("buggy://resource") + + # The error message should include the original exception details + assert "Error reading resource 'buggy://resource'" in str(excinfo.value) + assert "Internal error details" in str(excinfo.value) + + async def test_exception_converted_to_masked_resource_error(self): + """Test that other exceptions are masked when enabled.""" + manager = ResourceManager(mask_error_details=True) + + async def buggy_resource(): + """Resource that raises a ValueError.""" + raise ValueError("Internal error details") + + resource = FunctionResource( + uri=AnyUrl("buggy://resource"), + name="buggy_resource", + fn=buggy_resource, + ) + manager.add_resource(resource) + + with pytest.raises(ResourceError) as excinfo: + await manager.read_resource("buggy://resource") + + # The error message should not include the original exception details + assert "Error reading resource 'buggy://resource'" in str(excinfo.value) + assert "Internal error details" not in str(excinfo.value) diff --git a/tests/tools/test_tool_manager.py b/tests/tools/test_tool_manager.py index 7b82e26b1..8a4d6c556 100644 --- a/tests/tools/test_tool_manager.py +++ b/tests/tools/test_tool_manager.py @@ -778,8 +778,8 @@ class TestToolErrorHandling: with pytest.raises(ToolError, match="Specific tool error"): await manager.call_tool("error_tool", {"x": 42}) - async def test_exception_converted_to_tool_error(self): - """Test that other exceptions are converted to ToolError.""" + async def test_exception_converted_to_tool_error_with_details(self): + """Test that other exceptions include details by default.""" manager = ToolManager() def buggy_tool(x: int) -> int: @@ -791,7 +791,24 @@ class TestToolErrorHandling: with pytest.raises(ToolError) as excinfo: await manager.call_tool("buggy_tool", {"x": 42}) - # Exception message should contain the tool name but not the internal details + # Exception message should include the tool name and the internal details + assert "Error calling tool 'buggy_tool'" in str(excinfo.value) + assert "Internal error details" in str(excinfo.value) + + async def test_exception_converted_to_masked_tool_error(self): + """Test that other exceptions are masked when enabled.""" + manager = ToolManager(mask_error_details=True) + + def buggy_tool(x: int) -> int: + """Tool that raises a ValueError.""" + raise ValueError("Internal error details") + + manager.add_tool_from_fn(buggy_tool) + + with pytest.raises(ToolError) as excinfo: + await manager.call_tool("buggy_tool", {"x": 42}) + + # Exception message should only contain the tool name, not the internal details assert "Error calling tool 'buggy_tool'" in str(excinfo.value) assert "Internal error details" not in str(excinfo.value) @@ -808,8 +825,8 @@ class TestToolErrorHandling: with pytest.raises(ToolError, match="Async tool error"): await manager.call_tool("async_error_tool", {"x": 42}) - async def test_async_exception_converted_to_tool_error(self): - """Test that other exceptions from async tools are converted to ToolError.""" + async def test_async_exception_converted_to_tool_error_with_details(self): + """Test that other exceptions from async tools include details by default.""" manager = ToolManager() async def async_buggy_tool(x: int) -> int: @@ -818,6 +835,23 @@ class TestToolErrorHandling: manager.add_tool_from_fn(async_buggy_tool) + with pytest.raises(ToolError) as excinfo: + await manager.call_tool("async_buggy_tool", {"x": 42}) + + # Exception message should include the tool name and the internal details + assert "Error calling tool 'async_buggy_tool'" in str(excinfo.value) + assert "Internal async error details" in str(excinfo.value) + + async def test_async_exception_converted_to_masked_tool_error(self): + """Test that other exceptions from async tools are masked when enabled.""" + manager = ToolManager(mask_error_details=True) + + async def async_buggy_tool(x: int) -> int: + """Async tool that raises a ValueError.""" + raise ValueError("Internal async error details") + + manager.add_tool_from_fn(async_buggy_tool) + with pytest.raises(ToolError) as excinfo: await manager.call_tool("async_buggy_tool", {"x": 42}) From 26bc3271ff00478866f41ce80083ce054aadf87a Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 22 May 2025 09:35:32 -0400 Subject: [PATCH 056/114] Add versioning note to docs --- docs/getting-started/installation.mdx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx index 46552e5c4..b28201471 100644 --- a/docs/getting-started/installation.mdx +++ b/docs/getting-started/installation.mdx @@ -60,6 +60,18 @@ mcp = FastMCP("My MCP Server") Prior to `fastmcp==2.3.0` and `mcp==1.8.0`, the 2.x API always mirrored the 1.0 API. However, as the projects diverge, this can not be guaranteed. You may see deprecation warnings if you attempt to use 1.0 APIs in FastMCP 2.x. Please refer to this documentation for details on new capabilities. +## Versioning and Breaking Changes + +While we make every effort not to introduce backwards incompatible changes to our public APIs and behavior, FastMCP exists in a rapidly evolving MCP landscape. We're committed to bringing the most cutting-edge features to our users, which occasionally necessitates changes to existing functionality. + +As a practice, breaking changes will only occur on minor version changes (e.g., 2.3.x to 2.4.0). A minor version change indicates either: +- A new feature set significant enough to deserve a new line of features +- An implementation of breaking changes that could affect behavior if users upgrade blindly + +For users concerned about stability in production environments, we recommend pinning FastMCP to a specific version in your dependencies. + +Note that the "public API" includes the core functionality of the `FastMCP` server and its methods. It does not include private methods or objects that are stored as private attributes, as we do not expect users to rely on those implementation details. + ## Installing for Development If you plan to contribute to FastMCP, you should begin by cloning the repository and using uv to install all dependencies (development dependencies are installed automatically): From f51a768e2a6608639213829423e6454ec5ee74bf Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 22 May 2025 11:13:36 -0400 Subject: [PATCH 057/114] Update docs/getting-started/installation.mdx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/getting-started/installation.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx index b28201471..3707f6416 100644 --- a/docs/getting-started/installation.mdx +++ b/docs/getting-started/installation.mdx @@ -65,7 +65,7 @@ Prior to `fastmcp==2.3.0` and `mcp==1.8.0`, the 2.x API always mirrored the 1.0 While we make every effort not to introduce backwards incompatible changes to our public APIs and behavior, FastMCP exists in a rapidly evolving MCP landscape. We're committed to bringing the most cutting-edge features to our users, which occasionally necessitates changes to existing functionality. As a practice, breaking changes will only occur on minor version changes (e.g., 2.3.x to 2.4.0). A minor version change indicates either: -- A new feature set significant enough to deserve a new line of features +- A significant new feature set that warrants a new minor version - An implementation of breaking changes that could affect behavior if users upgrade blindly For users concerned about stability in production environments, we recommend pinning FastMCP to a specific version in your dependencies. From dcd161133d4b44362ed2ecd7626b34350f07edd6 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 22 May 2025 11:13:41 -0400 Subject: [PATCH 058/114] Update docs/getting-started/installation.mdx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/getting-started/installation.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx index 3707f6416..af632a44d 100644 --- a/docs/getting-started/installation.mdx +++ b/docs/getting-started/installation.mdx @@ -66,7 +66,7 @@ While we make every effort not to introduce backwards incompatible changes to ou As a practice, breaking changes will only occur on minor version changes (e.g., 2.3.x to 2.4.0). A minor version change indicates either: - A significant new feature set that warrants a new minor version -- An implementation of breaking changes that could affect behavior if users upgrade blindly +- Introducing breaking changes that may affect behavior on upgrade For users concerned about stability in production environments, we recommend pinning FastMCP to a specific version in your dependencies. From 983848f77193fb7b516e3b2d6d88ed6d44b1d42e Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 22 May 2025 11:16:11 -0400 Subject: [PATCH 059/114] Update installation.mdx --- docs/getting-started/installation.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx index af632a44d..aaff866e3 100644 --- a/docs/getting-started/installation.mdx +++ b/docs/getting-started/installation.mdx @@ -62,7 +62,7 @@ Prior to `fastmcp==2.3.0` and `mcp==1.8.0`, the 2.x API always mirrored the 1.0 ## Versioning and Breaking Changes -While we make every effort not to introduce backwards incompatible changes to our public APIs and behavior, FastMCP exists in a rapidly evolving MCP landscape. We're committed to bringing the most cutting-edge features to our users, which occasionally necessitates changes to existing functionality. +While we make every effort not to introduce backwards-incompatible changes to our public APIs and behavior, FastMCP exists in a rapidly evolving MCP landscape. We're committed to bringing the most cutting-edge features to our users, which occasionally necessitates changes to existing functionality. As a practice, breaking changes will only occur on minor version changes (e.g., 2.3.x to 2.4.0). A minor version change indicates either: - A significant new feature set that warrants a new minor version @@ -70,6 +70,8 @@ As a practice, breaking changes will only occur on minor version changes (e.g., For users concerned about stability in production environments, we recommend pinning FastMCP to a specific version in your dependencies. +Whenever possible, FastMCP will issue deprecation warnings when users attempt to use APIs that are either deprecated or destined for future removal. These warnings will be maintained for at least 1 minor version release, and may be maintained longer. + Note that the "public API" includes the core functionality of the `FastMCP` server and its methods. It does not include private methods or objects that are stored as private attributes, as we do not expect users to rely on those implementation details. ## Installing for Development From 06b9b98b6ce81a67dfeaefc71c799171924fef9b Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 22 May 2025 11:22:45 -0400 Subject: [PATCH 060/114] Raise an error if a Client is created with no servers in config --- src/fastmcp/client/transports.py | 6 +++++- tests/client/test_client.py | 12 ++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index e2ae60816..e2e8a7eb5 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -528,8 +528,12 @@ class MCPConfigTransport(ClientTransport): config = MCPConfig.from_dict(config) self.config = config + # if there are no servers, raise an error + if len(self.config.mcpServers) == 0: + raise ValueError("No MCP servers defined in the config") + # if there's exactly one server, create a client for that server - if len(self.config.mcpServers) == 1: + elif len(self.config.mcpServers) == 1: self.transport = list(self.config.mcpServers.values())[0].to_transport() # otherwise create a composite client diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 62bddf0ab..e85ca4fc5 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -673,6 +673,18 @@ class TestInferTransport: assert transport.transport.command == "echo" assert transport.transport.args == ["hello"] + def test_config_with_no_servers(self): + """Test that an empty MCPConfig raises a ValueError.""" + config = {"mcpServers": {}} + with pytest.raises(ValueError, match="No MCP servers defined in the config"): + infer_transport(config) + + def test_mcpconfigtransport_with_no_servers(self): + """Test that MCPConfigTransport raises a ValueError when initialized with an empty config.""" + config = {"mcpServers": {}} + with pytest.raises(ValueError, match="No MCP servers defined in the config"): + MCPConfigTransport(config=config) + def test_infer_composite_client(config): config = { "mcpServers": { From d618f9151bdde684dfe076430d9b5c860a446b14 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 22 May 2025 11:25:25 -0400 Subject: [PATCH 061/114] add transport to stdio server in mcpconfig, with default --- src/fastmcp/utilities/mcp_config.py | 7 ++++--- tests/utilities/test_mcp_config.py | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/fastmcp/utilities/mcp_config.py b/src/fastmcp/utilities/mcp_config.py index 19905f701..a9f5abee6 100644 --- a/src/fastmcp/utilities/mcp_config.py +++ b/src/fastmcp/utilities/mcp_config.py @@ -32,11 +32,12 @@ def infer_transport_type_from_url( return "streamable-http" -class LocalMCPServer(BaseModel): +class StdioMCPServer(BaseModel): command: str args: list[str] = Field(default_factory=list) env: dict[str, Any] = Field(default_factory=dict) cwd: str | None = None + transport: Literal["stdio"] = "stdio" def to_transport(self) -> StdioTransport: from fastmcp.client.transports import StdioTransport @@ -51,8 +52,8 @@ class LocalMCPServer(BaseModel): class RemoteMCPServer(BaseModel): url: str - transport: Literal["streamable-http", "sse", "http"] | None = None headers: dict[str, str] = Field(default_factory=dict) + transport: Literal["streamable-http", "sse", "http"] | None = None def to_transport(self) -> StreamableHttpTransport | SSETransport: from fastmcp.client.transports import SSETransport, StreamableHttpTransport @@ -69,7 +70,7 @@ class RemoteMCPServer(BaseModel): class MCPConfig(BaseModel): - mcpServers: dict[str, LocalMCPServer | RemoteMCPServer] + mcpServers: dict[str, StdioMCPServer | RemoteMCPServer] @classmethod def from_dict(cls, config: dict[str, Any]) -> MCPConfig: diff --git a/tests/utilities/test_mcp_config.py b/tests/utilities/test_mcp_config.py index 627a149f1..b7737da1d 100644 --- a/tests/utilities/test_mcp_config.py +++ b/tests/utilities/test_mcp_config.py @@ -9,7 +9,7 @@ from fastmcp.client.transports import ( StdioTransport, StreamableHttpTransport, ) -from fastmcp.utilities.mcp_config import LocalMCPServer, MCPConfig, RemoteMCPServer +from fastmcp.utilities.mcp_config import MCPConfig, RemoteMCPServer, StdioMCPServer def test_parse_single_stdio_config(): @@ -89,7 +89,7 @@ def test_parse_multiple_servers(): assert isinstance(mcp_config.mcpServers["test_server"], RemoteMCPServer) assert isinstance(mcp_config.mcpServers["test_server"].to_transport(), SSETransport) - assert isinstance(mcp_config.mcpServers["test_server_2"], LocalMCPServer) + assert isinstance(mcp_config.mcpServers["test_server_2"], StdioMCPServer) assert isinstance( mcp_config.mcpServers["test_server_2"].to_transport(), StdioTransport ) From aae1d8898ce04e3a924ba0d204b08e783d0848a3 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 22 May 2025 11:28:13 -0400 Subject: [PATCH 062/114] Add typing --- src/fastmcp/server/context.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py index 1b2c8ad45..7dc77f4d1 100644 --- a/src/fastmcp/server/context.py +++ b/src/fastmcp/server/context.py @@ -253,7 +253,9 @@ class Context: return fastmcp.server.dependencies.get_http_request() - def _parse_model_preferences(self, model_preferences) -> ModelPreferences | None: + def _parse_model_preferences( + self, model_preferences: ModelPreferences | str | list[str] | None + ) -> ModelPreferences | None: """ Validates and converts user input for model_preferences into a ModelPreferences object. @@ -273,12 +275,12 @@ class Context: """ if model_preferences is None: return None - if isinstance(model_preferences, ModelPreferences): + elif isinstance(model_preferences, ModelPreferences): return model_preferences - if isinstance(model_preferences, str): + elif isinstance(model_preferences, str): # Single model hint return ModelPreferences(hints=[ModelHint(name=model_preferences)]) - if isinstance(model_preferences, list): + elif isinstance(model_preferences, list): # List of model hints (strings) if not all(isinstance(h, str) for h in model_preferences): raise ValueError( @@ -288,6 +290,7 @@ class Context: return ModelPreferences( hints=[ModelHint(name=h) for h in model_preferences] ) - raise ValueError( - "model_preferences must be one of: ModelPreferences, str, list[str], or None." - ) + else: + raise ValueError( + "model_preferences must be one of: ModelPreferences, str, list[str], or None." + ) From 189389a3a48c45541bf73edd7db0b451afa8b220 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 22 May 2025 12:20:29 -0400 Subject: [PATCH 063/114] Ensure custom routes are respected --- src/fastmcp/server/http.py | 2 + src/fastmcp/server/server.py | 3 - tests/server/http/test_custom_routes.py | 105 ++++++++++++++++++ .../{ => http}/test_http_dependencies.py | 0 .../server/{ => http}/test_http_middleware.py | 0 5 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 tests/server/http/test_custom_routes.py rename tests/server/{ => http}/test_http_dependencies.py (100%) rename tests/server/{ => http}/test_http_middleware.py (100%) diff --git a/src/fastmcp/server/http.py b/src/fastmcp/server/http.py index 2a9cced00..de65c7e7e 100644 --- a/src/fastmcp/server/http.py +++ b/src/fastmcp/server/http.py @@ -241,6 +241,7 @@ def create_sse_app( # Add custom routes with lowest precedence if routes: server_routes.extend(routes) + server_routes.extend(server._additional_http_routes) # Add middleware if middleware: @@ -359,6 +360,7 @@ def create_streamable_http_app( # Add custom routes with lowest precedence if routes: server_routes.extend(routes) + server_routes.extend(server._additional_http_routes) # Add middleware if middleware: diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index c3f33fe81..389ad9600 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -854,7 +854,6 @@ class FastMCP(Generic[LifespanResultT]): auth_server_provider=self._auth_server_provider, auth_settings=self.settings.auth, debug=self.settings.debug, - routes=self._additional_http_routes, middleware=middleware, ) @@ -905,7 +904,6 @@ class FastMCP(Generic[LifespanResultT]): json_response=self.settings.json_response, stateless_http=self.settings.stateless_http, debug=self.settings.debug, - routes=self._additional_http_routes, middleware=middleware, ) elif transport == "sse": @@ -916,7 +914,6 @@ class FastMCP(Generic[LifespanResultT]): auth_server_provider=self._auth_server_provider, auth_settings=self.settings.auth, debug=self.settings.debug, - routes=self._additional_http_routes, middleware=middleware, ) diff --git a/tests/server/http/test_custom_routes.py b/tests/server/http/test_custom_routes.py new file mode 100644 index 000000000..5c988d1d4 --- /dev/null +++ b/tests/server/http/test_custom_routes.py @@ -0,0 +1,105 @@ +import pytest +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import Route + +from fastmcp import FastMCP +from fastmcp.server.http import create_sse_app, create_streamable_http_app + + +class TestCustomRoutes: + @pytest.fixture + def server_with_custom_route(self): + """Create a FastMCP server with a custom route.""" + server = FastMCP() + + @server.custom_route("/custom-route", methods=["GET"]) + async def custom_route(request: Request): + return JSONResponse({"message": "custom route"}) + + return server + + def test_custom_routes_via_server_http_app(self, server_with_custom_route): + """Test that custom routes are included when using server.http_app().""" + # Get the app via server.http_app() + app = server_with_custom_route.http_app() + + # Verify that the custom route is included + custom_route_found = False + for route in app.routes: + if isinstance(route, Route) and route.path == "/custom-route": + custom_route_found = True + break + + assert custom_route_found, "Custom route was not found in app routes" + + def test_custom_routes_via_streamable_http_app_direct( + self, server_with_custom_route + ): + """Test that custom routes are included when using create_streamable_http_app directly.""" + # Create the app by calling the constructor function directly + app = create_streamable_http_app( + server=server_with_custom_route, streamable_http_path="/api" + ) + + # Verify that the custom route is included + custom_route_found = False + for route in app.routes: + if isinstance(route, Route) and route.path == "/custom-route": + custom_route_found = True + break + + assert custom_route_found, "Custom route was not found in app routes" + + def test_custom_routes_via_sse_app_direct(self, server_with_custom_route): + """Test that custom routes are included when using create_sse_app directly.""" + # Create the app by calling the constructor function directly + app = create_sse_app( + server=server_with_custom_route, message_path="/message", sse_path="/sse" + ) + + # Verify that the custom route is included + custom_route_found = False + for route in app.routes: + if isinstance(route, Route) and route.path == "/custom-route": + custom_route_found = True + break + + assert custom_route_found, "Custom route was not found in app routes" + + def test_multiple_custom_routes( + self, + ): + """Test that multiple custom routes are included in both methods.""" + server = FastMCP() + + custom_paths = ["/route1", "/route2", "/route3"] + + # Add multiple custom routes + for path in custom_paths: + + @server.custom_route(path, methods=["GET"]) + async def custom_route(request: Request): + return JSONResponse({"message": f"route {path}"}) + + # Test with server.http_app() + app1 = server.http_app() + + # Test with direct constructor call + app2 = create_streamable_http_app(server=server, streamable_http_path="/api") + + # Check all routes are in both apps + for path in custom_paths: + # Check in app1 + route_in_app1 = any( + isinstance(route, Route) and route.path == path for route in app1.routes + ) + assert route_in_app1, f"Route {path} not found in server.http_app()" + + # Check in app2 + route_in_app2 = any( + isinstance(route, Route) and route.path == path for route in app2.routes + ) + assert route_in_app2, ( + f"Route {path} not found in create_streamable_http_app()" + ) diff --git a/tests/server/test_http_dependencies.py b/tests/server/http/test_http_dependencies.py similarity index 100% rename from tests/server/test_http_dependencies.py rename to tests/server/http/test_http_dependencies.py diff --git a/tests/server/test_http_middleware.py b/tests/server/http/test_http_middleware.py similarity index 100% rename from tests/server/test_http_middleware.py rename to tests/server/http/test_http_middleware.py From e6b1c6984c22359df777f0a858b6780b45aec05e Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 22 May 2025 15:06:10 -0400 Subject: [PATCH 064/114] Update route map logic --- docs/patterns/openapi.mdx | 118 +++++++++++- src/fastmcp/server/openapi.py | 187 ++++++++++++++++--- tests/deprecated/test_route_type_ignore.py | 113 +++++++++++ tests/server/test_openapi.py | 68 +++++++ tests/server/test_route_map_shortcuts.py | 207 +++++++++++++++++++++ 5 files changed, 656 insertions(+), 37 deletions(-) create mode 100644 tests/deprecated/test_route_type_ignore.py create mode 100644 tests/server/test_route_map_shortcuts.py diff --git a/docs/patterns/openapi.mdx b/docs/patterns/openapi.mdx index 0934cec8e..cab51a4aa 100644 --- a/docs/patterns/openapi.mdx +++ b/docs/patterns/openapi.mdx @@ -64,37 +64,34 @@ DEFAULT_ROUTE_MAPPINGS = [ RouteMap( methods=["GET"], pattern=r".*\{.*\}.*", - route_type=RouteType.RESOURCE_TEMPLATE, + mcp_type=MCPType.RESOURCE_TEMPLATE, ), # GET without path parameters -> Resource RouteMap( methods=["GET"], pattern=r".*", - route_type=RouteType.RESOURCE, + mcp_type=MCPType.RESOURCE, ), # All other methods -> Tool - RouteMap( - methods="*", - pattern=r".*", - route_type=RouteType.TOOL, - ), + ALL_TOOLS(), ] ``` + ### 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 +from fastmcp.server.openapi import RouteMap, MCPType # Custom mapping rules custom_maps = [ # Force all analytics endpoints to be Tools RouteMap(methods=["GET"], pattern=r"^/analytics/.*", - route_type=RouteType.TOOL) + mcp_type=MCPType.TOOL) ] # Apply custom mappings @@ -105,6 +102,9 @@ mcp = await FastMCP.from_openapi( ) ``` + +For backward compatibility, FastMCP still supports the `route_type` parameter and `RouteType` enum, but they are deprecated and will be removed in a future version. You will see deprecation warnings if you use them. + ### All Routes as Tools @@ -127,13 +127,46 @@ mcp = FastMCP.from_openapi( openapi_spec=spec, client=api_client, route_maps=[ - RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL) + RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL) ] ) ``` Note that `all_routes_as_tools` and `route_maps` cannot be used together - if you need more complex mapping rules, use `route_maps` instead. +### Excluding Routes + +If you want to exclude certain routes from being converted to MCP components, you can map them to `MCPType.EXCLUDE`. This is useful for endpoints that should not be accessible to the agent. + +```python +from fastmcp.server.openapi import RouteMap, MCPType + +# Custom mapping rules to exclude specific routes +custom_maps = [ + # Exclude all admin endpoints + RouteMap( + methods="*", + pattern=r"^/admin/.*", + mcp_type=MCPType.EXCLUDE + ), + # Exclude analytics GET endpoints + RouteMap( + methods=["GET"], + pattern=r"^/analytics/.*", + mcp_type=MCPType.EXCLUDE + ) +] + +# Apply custom mappings +mcp = FastMCP.from_openapi( + openapi_spec=spec, + client=api_client, + route_maps=custom_maps +) +``` + +When a route is mapped to `MCPType.EXCLUDE`, FastMCP will log its presence but won't create any MCP component for it, effectively making it invisible to clients and agents using the MCP server. + ## How It Works 1. FastMCP parses your OpenAPI spec to extract routes and schemas @@ -261,3 +294,68 @@ if __name__ == "__main__": mcp.run() ``` +### Route Map Shortcuts + +FastMCP provides several shortcut functions to create common route maps more easily: + +```python +from fastmcp.server.openapi import ( + ALL_TOOLS, + EXCLUDE_ALL, + EXCLUDE_PATTERN, + PATTERN_AS_TOOLS, +) + +# Create an MCP server with custom route maps using shortcuts +mcp = FastMCP.from_openapi( + openapi_spec=spec, + client=api_client, + route_maps=[ + # First exclude all admin endpoints + EXCLUDE_PATTERN(r"^/admin/.*"), + + # Make all /api/v1 endpoints tools + PATTERN_AS_TOOLS(r"^/api/v1/.*"), + + # Make all remaining routes tools + ALL_TOOLS(), + ] +) +``` + +Available shortcuts: + +| Shortcut Function | Description | +|------------------|-------------| +| `ALL_TOOLS()` | Converts all matching routes to tools | +| `EXCLUDE_ALL()` | Excludes all matching routes from being converted to any component | +| `PATTERN_AS_TOOLS(pattern)` | Converts routes matching a specific pattern to tools | +| `EXCLUDE_PATTERN(pattern)` | Excludes routes matching a specific pattern | + +These shortcuts are particularly useful for: + +1. Converting all remaining unmatched routes to tools (use `ALL_TOOLS()`) +2. Excluding whole sections of your API (use `EXCLUDE_PATTERN("/path/.*")`) +3. Converting routes matching specific patterns to tools (use `PATTERN_AS_TOOLS("/path/.*")`) + +The `all_routes_as_tools=True` parameter is equivalent to using just `[ALL_TOOLS()]` as your route maps. + + +You can use `EXCLUDE_ALL()` as the last entry in your custom route maps to completely ignore the default route maps. Since custom route maps are applied first and default maps are appended afterward, having `EXCLUDE_ALL()` at the end of your custom maps will match any routes that your earlier custom rules didn't match, preventing the default maps from having any effect. + +```python +# Create server that only uses custom route maps, ignoring defaults +mcp = FastMCP.from_openapi( + openapi_spec=spec, + client=api_client, + route_maps=[ + # Routes to keep as tools + PATTERN_AS_TOOLS(r"^/api/v1/.*"), + + # Exclude everything else (ignores default route maps) + EXCLUDE_ALL(), + ] +) +``` + + diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py index 687790653..ed3e21aa1 100644 --- a/src/fastmcp/server/openapi.py +++ b/src/fastmcp/server/openapi.py @@ -5,8 +5,9 @@ from __future__ import annotations import enum import json import re +import warnings from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, field from re import Pattern from typing import TYPE_CHECKING, Any, Literal @@ -33,46 +34,176 @@ logger = get_logger(__name__) HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"] -class RouteType(enum.Enum): - """Type of FastMCP component to create from a route.""" +class MCPType(enum.Enum): + """Type of FastMCP component to create from a route. + + Enum values: + TOOL: Convert the route to a callable Tool + RESOURCE: Convert the route to a Resource (typically GET endpoints) + RESOURCE_TEMPLATE: Convert the route to a ResourceTemplate (typically GET with path params) + PROMPT: Convert the route to a Prompt (not yet implemented) + EXCLUDE: Exclude the route from being converted to any MCP component + IGNORE: Deprecated, use EXCLUDE instead + """ TOOL = "TOOL" RESOURCE = "RESOURCE" RESOURCE_TEMPLATE = "RESOURCE_TEMPLATE" PROMPT = "PROMPT" - IGNORE = "IGNORE" + EXCLUDE = "EXCLUDE" + + +# Keep RouteType as an alias to MCPType for backward compatibility +class RouteType(enum.Enum): + """ + Deprecated: Use MCPType instead. + + This enum is kept for backward compatibility and will be removed in a future version. + """ + + TOOL = "TOOL" + RESOURCE = "RESOURCE" + RESOURCE_TEMPLATE = "RESOURCE_TEMPLATE" + PROMPT = "PROMPT" + EXCLUDE = "EXCLUDE" + IGNORE = "IGNORE" # Deprecated, use EXCLUDE instead + + def __new__(cls, value): + # Deprecated in 2.4.1 + warnings.warn( + "RouteType is deprecated and will be removed in a future version. " + "Use MCPType instead.", + DeprecationWarning, + stacklevel=2, + ) + + # Add a specific warning for the deprecated IGNORE value + if value == "IGNORE": + warnings.warn( + "RouteType.IGNORE is deprecated and will be removed in a future version. " + "Use MCPType.EXCLUDE instead.", + DeprecationWarning, + stacklevel=2, + ) + + instance = object.__new__(cls) + instance._value_ = value + return instance @dataclass class RouteMap: """Mapping configuration for HTTP routes to FastMCP component types.""" - methods: list[HttpMethod] | Literal["*"] - pattern: Pattern[str] | str - route_type: RouteType + methods: list[HttpMethod] | Literal["*"] = field(default="*") + pattern: Pattern[str] | str = field(default=r".*") + mcp_type: MCPType | None = field(default=None) + route_type: RouteType | MCPType | None = field(default=None) + + def __post_init__(self): + """Validate and process the route map after initialization.""" + # Handle backward compatibility for route_type + if self.mcp_type is None and self.route_type is not None: + warnings.warn( + "The 'route_type' parameter is deprecated and will be removed in a future version. " + "Use 'mcp_type' instead with the appropriate MCPType value.", + DeprecationWarning, + stacklevel=2, + ) + + # Check for the deprecated IGNORE value + if self.route_type == RouteType.IGNORE: + warnings.warn( + "RouteType.IGNORE is deprecated and will be removed in a future version. " + "Use MCPType.EXCLUDE instead.", + DeprecationWarning, + stacklevel=2, + ) + + # Convert from RouteType to MCPType if needed + if isinstance(self.route_type, RouteType): + route_type_name = self.route_type.name + if route_type_name == "IGNORE": + route_type_name = "EXCLUDE" + self.mcp_type = getattr(MCPType, route_type_name) + else: + self.mcp_type = self.route_type + elif self.mcp_type is None: + raise ValueError("`mcp_type` must be provided") + + # Set route_type to match mcp_type for backward compatibility + if self.route_type is None: + self.route_type = self.mcp_type + + +# Common route map pattern functions +def EXCLUDE_ALL() -> RouteMap: + """ + Create a RouteMap that excludes all routes that haven't been matched by earlier rules. + + This is useful as the last route map to exclude any routes that don't match specific patterns. + + Returns: + RouteMap: A route map that excludes all routes + """ + return RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.EXCLUDE) + + +def ALL_TOOLS() -> RouteMap: + """ + Create a RouteMap that converts all routes to tools that haven't been matched by earlier rules. + + This is useful to replace the last item in the default route mappings to make all unmatched routes tools. + + Returns: + RouteMap: A route map that converts all routes to tools + """ + return RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL) + + +def PATTERN_AS_TOOLS(pattern: str) -> RouteMap: + """ + Create a RouteMap that converts routes matching a specific pattern to tools. + + Args: + pattern: Regex pattern to match routes + + Returns: + RouteMap: A route map that converts routes matching the pattern to tools + """ + return RouteMap(methods="*", pattern=pattern, mcp_type=MCPType.TOOL) + + +def EXCLUDE_PATTERN(pattern: str) -> RouteMap: + """ + Create a RouteMap that excludes routes matching a specific pattern. + + Args: + pattern: Regex pattern to match routes to exclude + + Returns: + RouteMap: A route map that excludes routes matching the pattern + """ + return RouteMap(methods="*", pattern=pattern, mcp_type=MCPType.EXCLUDE) # Default route mappings as a list, where order determines priority DEFAULT_ROUTE_MAPPINGS = [ # GET requests with path parameters go to ResourceTemplate RouteMap( - methods=["GET"], pattern=r".*\{.*\}.*", route_type=RouteType.RESOURCE_TEMPLATE + methods=["GET"], pattern=r".*\{.*\}.*", mcp_type=MCPType.RESOURCE_TEMPLATE ), # GET requests without path parameters go to Resource - RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE), + RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE), # All other HTTP methods go to Tool - RouteMap( - methods=["POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"], - pattern=r".*", - route_type=RouteType.TOOL, - ), + ALL_TOOLS(), ] def _determine_route_type( route: openapi.HTTPRoute, mappings: list[RouteMap], -) -> RouteType: +) -> MCPType: """ Determines the FastMCP component type based on the route and mappings. @@ -81,7 +212,7 @@ def _determine_route_type( mappings: List of RouteMap objects in priority order Returns: - RouteType for this route + MCPType for this route """ # Check mappings in priority order (first match wins) for route_map in mappings: @@ -94,13 +225,15 @@ def _determine_route_type( pattern_matches = re.search(route_map.pattern, route.path) if pattern_matches: + # We know mcp_type is not None here due to post_init validation + assert route_map.mcp_type is not None logger.debug( - f"Route {route.method} {route.path} matched mapping to {route_map.route_type.name}" + f"Route {route.method} {route.path} matched mapping to {route_map.mcp_type.name}" ) - return route_map.route_type + return route_map.mcp_type # Default fallback - return RouteType.TOOL + return MCPType.TOOL # Placeholder function to provide function metadata @@ -555,13 +688,13 @@ class FastMCPOpenAPI(FastMCP): RouteMap( methods=["GET", "POST", "PATCH"], pattern=r".*/users/.*", - route_type=RouteType.RESOURCE_TEMPLATE + mcp_type=MCPType.RESOURCE_TEMPLATE ), # Map all analytics endpoints to Tool RouteMap( methods=["GET"], pattern=r".*/analytics/.*", - route_type=RouteType.TOOL + mcp_type=MCPType.TOOL ), ] @@ -615,19 +748,19 @@ class FastMCPOpenAPI(FastMCP): path_name = "_".join(p for p in path_parts if not p.startswith("{")) operation_id = f"{route.method.lower()}_{path_name}" - if route_type == RouteType.TOOL: + if route_type == MCPType.TOOL: self._create_openapi_tool(route, operation_id) - elif route_type == RouteType.RESOURCE: + elif route_type == MCPType.RESOURCE: self._create_openapi_resource(route, operation_id) - elif route_type == RouteType.RESOURCE_TEMPLATE: + elif route_type == MCPType.RESOURCE_TEMPLATE: self._create_openapi_template(route, operation_id) - elif route_type == RouteType.PROMPT: + elif route_type == MCPType.PROMPT: # Not implemented yet logger.warning( f"PROMPT route type not implemented: {route.method} {route.path}" ) - elif route_type == RouteType.IGNORE: - logger.info(f"Ignoring route: {route.method} {route.path}") + elif route_type == MCPType.EXCLUDE: + logger.info(f"Excluding route: {route.method} {route.path}") logger.info(f"Created FastMCP OpenAPI server with {len(http_routes)} routes") diff --git a/tests/deprecated/test_route_type_ignore.py b/tests/deprecated/test_route_type_ignore.py new file mode 100644 index 000000000..1382d7137 --- /dev/null +++ b/tests/deprecated/test_route_type_ignore.py @@ -0,0 +1,113 @@ +"""Tests for the deprecated RouteType.IGNORE.""" + +import warnings + +import httpx +import pytest + +from fastmcp.server.openapi import ( + FastMCPOpenAPI, + MCPType, + RouteMap, + RouteType, +) + + +def test_route_type_ignore_deprecation_warning(): + """Test that using RouteType.IGNORE emits a deprecation warning.""" + # Let's manually capture the warnings + + # Record all warnings + with warnings.catch_warnings(record=True) as recorded: + # Make sure warnings are always triggered + warnings.simplefilter("always") + + # Create a RouteMap with RouteType.IGNORE + route_map = RouteMap( + methods=["GET"], pattern=r"^/analytics$", route_type=RouteType.IGNORE + ) + + # Check for the expected warnings in the recorded warnings + route_type_warning = False + ignore_warning = False + + for w in recorded: + if issubclass(w.category, DeprecationWarning): + message = str(w.message) + if "route_type' parameter is deprecated" in message: + route_type_warning = True + if "RouteType.IGNORE is deprecated" in message: + ignore_warning = True + + # Make sure both warnings were triggered + assert route_type_warning, "Missing 'route_type' deprecation warning" + assert ignore_warning, "Missing 'RouteType.IGNORE' deprecation warning" + + # Verify that RouteType.IGNORE was converted to MCPType.EXCLUDE + assert route_map.mcp_type == MCPType.EXCLUDE + + +class TestRouteTypeIgnoreDeprecation: + """Test class for the deprecated RouteType.IGNORE.""" + + @pytest.fixture + def basic_openapi_spec(self) -> dict: + """Create a simple OpenAPI spec for testing.""" + return { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "paths": { + "/items": { + "get": { + "operationId": "get_items", + "summary": "Get all items", + "responses": {"200": {"description": "Success"}}, + } + }, + "/analytics": { + "get": { + "operationId": "get_analytics", + "summary": "Get analytics data", + "responses": {"200": {"description": "Success"}}, + } + }, + }, + } + + @pytest.fixture + async def mock_client(self) -> httpx.AsyncClient: + """Create a mock client for testing.""" + + async def _responder(request): + return httpx.Response(200, json={"success": True}) + + return httpx.AsyncClient(transport=httpx.MockTransport(_responder)) + + async def test_route_type_ignore_conversion(self, basic_openapi_spec, mock_client): + """Test that routes with RouteType.IGNORE are properly excluded.""" + # Capture the deprecation warning without checking the exact message + with pytest.warns(DeprecationWarning): + server = FastMCPOpenAPI( + openapi_spec=basic_openapi_spec, + client=mock_client, + route_maps=[ + # Use the deprecated RouteType.IGNORE + RouteMap( + methods=["GET"], + pattern=r"^/analytics$", + route_type=RouteType.IGNORE, + ), + # Make everything else a resource + RouteMap( + methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE + ), + ], + ) + + # Check that the analytics route was excluded (converted from IGNORE to EXCLUDE) + resources = await server.get_resources() + resource_uris = [str(r.uri) for r in resources.values()] + + # Analytics should be excluded + assert "resource://openapi/get_items" in resource_uris + assert "resource://openapi/get_analytics" not in resource_uris diff --git a/tests/server/test_openapi.py b/tests/server/test_openapi.py index 3149ac19c..f38ae4acc 100644 --- a/tests/server/test_openapi.py +++ b/tests/server/test_openapi.py @@ -2152,3 +2152,71 @@ class TestAllRoutesAsTools: ) ], ) + + +class TestRouteTypeExclude: + @pytest.fixture + def basic_openapi_spec(self) -> dict: + return { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "paths": { + "/items": { + "get": { + "operationId": "get_items", + "summary": "Get all items", + "responses": {"200": {"description": "Success"}}, + } + }, + "/users": { + "get": { + "operationId": "get_users", + "summary": "Get all users", + "responses": {"200": {"description": "Success"}}, + } + }, + "/analytics": { + "get": { + "operationId": "get_analytics", + "summary": "Get analytics data", + "responses": {"200": {"description": "Success"}}, + } + }, + }, + } + + @pytest.fixture + async def mock_client(self) -> httpx.AsyncClient: + async def _responder(request): + return httpx.Response(200, json={"success": True}) + + return httpx.AsyncClient(transport=httpx.MockTransport(_responder)) + + async def test_exclude_routes(self, basic_openapi_spec, mock_client): + # Create a server with custom mappings that exclude specific routes + server = FastMCPOpenAPI( + openapi_spec=basic_openapi_spec, + client=mock_client, + route_maps=[ + # Exclude analytics endpoints + RouteMap( + methods=["GET"], + pattern=r"^/analytics$", + route_type=RouteType.IGNORE, + ), + # Make everything else a resource + RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE), + ], + ) + + # Check that resources were created for non-excluded routes + resources = await server.get_resources() + resource_uris = [str(r.uri) for r in resources.values()] + + # The /analytics endpoint should be excluded + assert "resource://openapi/get_items" in resource_uris + assert "resource://openapi/get_users" in resource_uris + assert "resource://openapi/get_analytics" not in resource_uris + + # Should only have 2 resources (analytics is excluded) + assert len(resources) == 2 diff --git a/tests/server/test_route_map_shortcuts.py b/tests/server/test_route_map_shortcuts.py new file mode 100644 index 000000000..a64be9910 --- /dev/null +++ b/tests/server/test_route_map_shortcuts.py @@ -0,0 +1,207 @@ +"""Tests for the route map shortcut functions.""" + +import httpx +import pytest + +from fastmcp.server.openapi import ( + ALL_TOOLS, + EXCLUDE_ALL, + EXCLUDE_PATTERN, + PATTERN_AS_TOOLS, + FastMCPOpenAPI, + MCPType, + RouteMap, + RouteType, +) + + +class TestRouteMapShortcuts: + """Tests for the route map shortcut functions.""" + + def test_functions_return_correct_route_maps(self): + """Test that each shortcut function returns a RouteMap with the expected properties.""" + # Test EXCLUDE_ALL + exclude_all = EXCLUDE_ALL() + assert isinstance(exclude_all, RouteMap) + assert exclude_all.methods == "*" + assert exclude_all.pattern == ".*" + assert exclude_all.mcp_type == MCPType.EXCLUDE + + # Test ALL_TOOLS + all_tools = ALL_TOOLS() + assert isinstance(all_tools, RouteMap) + assert all_tools.methods == "*" + assert all_tools.pattern == ".*" + assert all_tools.mcp_type == MCPType.TOOL + + # Test PATTERN_AS_TOOLS + pattern = r"^/api/.*" + pattern_as_tools = PATTERN_AS_TOOLS(pattern) + assert isinstance(pattern_as_tools, RouteMap) + assert pattern_as_tools.methods == "*" + assert pattern_as_tools.pattern == pattern + assert pattern_as_tools.mcp_type == MCPType.TOOL + + # Test EXCLUDE_PATTERN + pattern = r"^/admin/.*" + exclude_pattern = EXCLUDE_PATTERN(pattern) + assert isinstance(exclude_pattern, RouteMap) + assert exclude_pattern.methods == "*" + assert exclude_pattern.pattern == pattern + assert exclude_pattern.mcp_type == MCPType.EXCLUDE + + def test_backward_compatibility(self): + """Test that backward compatibility with RouteType and route_type works.""" + # Test creating a RouteMap with route_type + with pytest.warns(DeprecationWarning): + route_map = RouteMap( + methods=["GET"], pattern=r".*", route_type=RouteType.TOOL + ) + assert route_map.mcp_type == MCPType.TOOL + + # Test accessing fields on RouteType directly + # Note: importing RouteType already causes the deprecation warning, + # so we don't need to check for it again here + rt = RouteType.RESOURCE + assert rt.value == "RESOURCE" + assert rt.name == "RESOURCE" + + +class TestRouteMapShortcutsIntegration: + """Integration tests for the route map shortcut functions with FastMCPOpenAPI.""" + + @pytest.fixture + def basic_openapi_spec(self) -> dict: + """Create a simple OpenAPI spec for testing.""" + return { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "paths": { + "/items": { + "get": { + "operationId": "get_items", + "summary": "Get all items", + "responses": {"200": {"description": "Success"}}, + }, + "post": { + "operationId": "create_item", + "summary": "Create an item", + "responses": {"201": {"description": "Created"}}, + }, + }, + "/users": { + "get": { + "operationId": "get_users", + "summary": "Get all users", + "responses": {"200": {"description": "Success"}}, + }, + }, + "/admin": { + "get": { + "operationId": "get_admin", + "summary": "Admin endpoint", + "responses": {"200": {"description": "Success"}}, + }, + }, + "/items/{item_id}": { + "get": { + "operationId": "get_item", + "summary": "Get an item by ID", + "parameters": [ + { + "name": "item_id", + "in": "path", + "required": True, + "schema": {"type": "string"}, + } + ], + "responses": {"200": {"description": "Success"}}, + }, + }, + }, + } + + @pytest.fixture + async def mock_client(self) -> httpx.AsyncClient: + """Create a mock client for testing.""" + + async def _responder(request): + return httpx.Response(200, json={"success": True}) + + return httpx.AsyncClient(transport=httpx.MockTransport(_responder)) + + async def test_all_tools(self, basic_openapi_spec, mock_client): + """Test using ALL_TOOLS() to convert all routes to tools.""" + server = FastMCPOpenAPI( + openapi_spec=basic_openapi_spec, + client=mock_client, + route_maps=[ALL_TOOLS()], + ) + + # Check that all routes are tools + tools = await server.get_tools() + resources = await server.get_resources() + templates = await server.get_resource_templates() + + # All 5 routes should be tools + assert len(tools) == 5 + assert len(resources) == 0 + assert len(templates) == 0 + + # Check that all expected tools exist + tool_names = [t.name for t in tools.values()] + assert "get_items" in tool_names + assert "create_item" in tool_names + assert "get_users" in tool_names + assert "get_admin" in tool_names + assert "get_item" in tool_names + + async def test_exclude_pattern(self, basic_openapi_spec, mock_client): + """Test using EXCLUDE_PATTERN() to exclude specific routes.""" + server = FastMCPOpenAPI( + openapi_spec=basic_openapi_spec, + client=mock_client, + route_maps=[ + # Exclude admin endpoints + EXCLUDE_PATTERN(r"^/admin"), + # Make everything else a tool + ALL_TOOLS(), + ], + ) + + # Check that admin route is excluded + tools = await server.get_tools() + tool_names = [t.name for t in tools.values()] + + # All routes except admin should be tools + assert "get_items" in tool_names + assert "create_item" in tool_names + assert "get_users" in tool_names + assert "get_item" in tool_names + assert "get_admin" not in tool_names # This should be excluded + + async def test_pattern_as_tools(self, basic_openapi_spec, mock_client): + """Test using PATTERN_AS_TOOLS() to convert routes matching a pattern to tools.""" + server = FastMCPOpenAPI( + openapi_spec=basic_openapi_spec, + client=mock_client, + route_maps=[ + # Make /items routes tools regardless of method + PATTERN_AS_TOOLS(r"^/items"), + # Make everything else a resource + RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.RESOURCE), + ], + ) + + # Check that /items routes are tools + tools = await server.get_tools() + tool_names = [t.name for t in tools.values()] + assert "get_items" in tool_names + assert "create_item" in tool_names + assert "get_item" in tool_names + + # Check that other routes are resources + resources = await server.get_resources() + resource_names = [r.name for r in resources.values()] + assert "get_users" in resource_names + assert "get_admin" in resource_names From 4c3bf806523f5d86f3eed200f0f1161b6a114a95 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 22 May 2025 15:46:46 -0400 Subject: [PATCH 065/114] Add custom naming and deprecate all_routes_as_tools --- docs/patterns/openapi.mdx | 284 +++++++++---------- src/fastmcp/server/openapi.py | 185 ++++++++---- src/fastmcp/server/server.py | 33 ++- tests/server/test_openapi.py | 157 +++++----- tests/server/test_openapi_naming.py | 231 +++++++++++++++ tests/server/test_openapi_path_parameters.py | 6 +- tests/server/test_route_map_shortcuts.py | 3 +- 7 files changed, 612 insertions(+), 287 deletions(-) create mode 100644 tests/server/test_openapi_naming.py diff --git a/docs/patterns/openapi.mdx b/docs/patterns/openapi.mdx index cab51a4aa..68d5c6bd4 100644 --- a/docs/patterns/openapi.mdx +++ b/docs/patterns/openapi.mdx @@ -31,20 +31,61 @@ if __name__ == "__main__": ### Timeout -You can set a timeout for all API requests: +You can set a timeout for all requests by providing a `timeout` parameter (in seconds): ```python -# Set a 5 second timeout for all requests mcp = FastMCP.from_openapi( openapi_spec=spec, - client=api_client, - timeout=5.0 + client=api_client, + timeout=30.0 # 30 second timeout ) ``` -This timeout is applied to all requests made by tools, resources, and resource templates. +### Component Naming -## Route Mapping + + +You can customize how FastMCP names the components generated from your OpenAPI spec: + +```python +# Custom naming function +def my_component_namer(route, mcp_type, default_name): + # Create custom names based on the route and component type + if route.operation_id: + return route.operation_id + + # For example, prefix with component type + prefix = { + MCPType.TOOL: "tool_", + MCPType.RESOURCE: "resource_", + MCPType.RESOURCE_TEMPLATE: "template_", + }.get(mcp_type, "") + + path_name = route.path.replace("/", "_").strip("_") + return f"{prefix}{path_name}" + +mcp = FastMCP.from_openapi( + openapi_spec=spec, + client=api_client, + component_namer=my_component_namer +) +``` + +By default, FastMCP generates component names as follows: + +- If the route has an `operationId` in the OpenAPI spec, that is used +- Otherwise, the name is generated from the route path: + - For `GET` routes mapped to resources: Just the resource name (e.g., `/users` → `users`) + - For routes with path parameters mapped to templates: The path with parameter names (e.g., `/users/{id}` → `users_id`) + - For other methods mapped to tools: Method + resource name (e.g., `POST /users` → `post_users`) + +#### Handling Name Collisions + +When multiple routes would generate the same component name, FastMCP automatically appends a number suffix to ensure uniqueness (e.g., `users`, `users_2`, `users_3`). You'll see these numbered suffixes in the component names returned by `get_tools()`, `get_resources()`, etc. + +If you need more control over naming, you can provide a custom `component_namer` function that handles potential collisions in your own way. + +### Route Mapping By default, OpenAPI routes are mapped to MCP components based on these rules: @@ -54,7 +95,6 @@ By default, OpenAPI routes are mapped to MCP components based on these rules: | `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 | - 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 @@ -79,7 +119,7 @@ DEFAULT_ROUTE_MAPPINGS = [ ] ``` -### Custom Route Maps +#### 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. @@ -95,7 +135,7 @@ custom_maps = [ ] # Apply custom mappings -mcp = await FastMCP.from_openapi( +mcp = FastMCP.from_openapi( openapi_spec=spec, client=api_client, route_maps=custom_maps @@ -106,23 +146,19 @@ mcp = await FastMCP.from_openapi( For backward compatibility, FastMCP still supports the `route_type` parameter and `RouteType` enum, but they are deprecated and will be removed in a future version. You will see deprecation warnings if you use them. -### All Routes as Tools +#### All Routes as Tools -When building AI agent backends, it's often useful to treat all routes as callable tools regardless of their HTTP method. You can use the `all_routes_as_tools` parameter to automatically map every route to a Tool: +When building AI agent backends, it's often useful to treat all routes as callable tools regardless of their HTTP method. You can use the `ALL_TOOLS()` shortcut or create a custom route map: ```python -# Make all endpoints tools, regardless of HTTP method +# Make all endpoints tools using the shortcut mcp = FastMCP.from_openapi( openapi_spec=spec, client=api_client, - all_routes_as_tools=True + route_maps=[ALL_TOOLS()] ) -``` -This is equivalent to defining a single route map that matches all routes: - -```python -# Same effect as all_routes_as_tools=True +# Same effect using a custom route map mcp = FastMCP.from_openapi( openapi_spec=spec, client=api_client, @@ -132,9 +168,7 @@ mcp = FastMCP.from_openapi( ) ``` -Note that `all_routes_as_tools` and `route_maps` cannot be used together - if you need more complex mapping rules, use `route_maps` instead. - -### Excluding Routes +#### Excluding Routes If you want to exclude certain routes from being converted to MCP components, you can map them to `MCPType.EXCLUDE`. This is useful for endpoints that should not be accessible to the agent. @@ -167,134 +201,38 @@ mcp = FastMCP.from_openapi( When a route is mapped to `MCPType.EXCLUDE`, FastMCP will log its presence but won't create any MCP component for it, effectively making it invisible to clients and agents using the MCP server. -## 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 - -### Request Parameter Handling - -FastMCP carefully handles different types of parameters in OpenAPI requests: - -#### Query Parameters - -By default, FastMCP will only include query parameters that have non-empty values. Parameters with `None` values or empty strings (`""`) are automatically filtered out of requests. This ensures that API servers don't receive unnecessary empty parameters that might cause issues. - -For example, if you call a tool with these parameters: -```python -await client.call_tool("search_products", { - "category": "electronics", # Will be included - "min_price": 100, # Will be included - "max_price": None, # Will be excluded - "brand": "", # Will be excluded -}) -``` - -The resulting HTTP request will only include `category=electronics&min_price=100`. - -#### Path Parameters - -For path parameters, which are typically required by REST APIs, FastMCP filters out `None` values and checks that all required path parameters are provided. If a required path parameter is missing or `None`, an error will be raised. +You can customize this behavior by providing a list of `RouteMap` objects: ```python -# This will work -await client.call_tool("get_product", {"product_id": 123}) +from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap, MCPType -# This will raise ValueError: "Missing required path parameters: {'product_id'}" -await client.call_tool("get_product", {"product_id": None}) +# Custom route mappings +custom_mappings = [ + # Convert all user-related routes to tools + RouteMap( + methods=["GET", "POST", "PUT", "DELETE"], + pattern=r"^/users.*", + mcp_type=MCPType.TOOL + ), + # Exclude analytics routes + RouteMap( + methods=["*"], # All methods + pattern=r"^/analytics.*", + mcp_type=MCPType.EXCLUDE + ), +] + +# Create server with custom mappings +mcp = FastMCPOpenAPI( + openapi_spec=spec, + client=httpx.AsyncClient(), + route_maps=custom_mappings, +) ``` -## Complete Example +#### Route Map Shortcuts -```python [expandable] -import asyncio - -import httpx - -from fastmcp import FastMCP - -# Sample OpenAPI spec for a Pet Store API -petstore_spec = { - "openapi": "3.0.0", - "info": { - "title": "Pet Store API", - "version": "1.0.0", - "description": "A sample API for managing pets", - }, - "paths": { - "/pets": { - "get": { - "operationId": "listPets", - "summary": "List all pets", - "responses": {"200": {"description": "A list of pets"}}, - }, - "post": { - "operationId": "createPet", - "summary": "Create a new pet", - "responses": {"201": {"description": "Pet created successfully"}}, - }, - }, - "/pets/{petId}": { - "get": { - "operationId": "getPet", - "summary": "Get a pet by ID", - "parameters": [ - { - "name": "petId", - "in": "path", - "required": True, - "schema": {"type": "string"}, - } - ], - "responses": { - "200": {"description": "Pet details"}, - "404": {"description": "Pet not found"}, - }, - } - }, - }, -} - - -async def check_mcp(mcp: FastMCP): - # List what components were created - tools = await mcp.get_tools() - resources = await mcp.get_resources() - templates = await mcp.get_resource_templates() - - print( - f"{len(tools)} Tool(s): {', '.join([t.name for t in tools.values()])}" - ) # Should include createPet - print( - f"{len(resources)} Resource(s): {', '.join([r.name for r in resources.values()])}" - ) # Should include listPets - print( - f"{len(templates)} Resource Template(s): {', '.join([t.name for t in templates.values()])}" - ) # Should include getPet - - return mcp - - -if __name__ == "__main__": - # Client for the Pet Store API - client = httpx.AsyncClient(base_url="https://petstore.example.com/api") - - # Create the MCP server - mcp = FastMCP.from_openapi( - openapi_spec=petstore_spec, client=client, name="PetStore" - ) - - asyncio.run(check_mcp(mcp)) - - # Start the MCP server - mcp.run() -``` - -### Route Map Shortcuts + FastMCP provides several shortcut functions to create common route maps more easily: @@ -338,8 +276,6 @@ These shortcuts are particularly useful for: 2. Excluding whole sections of your API (use `EXCLUDE_PATTERN("/path/.*")`) 3. Converting routes matching specific patterns to tools (use `PATTERN_AS_TOOLS("/path/.*")`) -The `all_routes_as_tools=True` parameter is equivalent to using just `[ALL_TOOLS()]` as your route maps. - You can use `EXCLUDE_ALL()` as the last entry in your custom route maps to completely ignore the default route maps. Since custom route maps are applied first and default maps are appended afterward, having `EXCLUDE_ALL()` at the end of your custom maps will match any routes that your earlier custom rules didn't match, preventing the default maps from having any effect. @@ -359,3 +295,61 @@ mcp = FastMCP.from_openapi( ``` +## 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 + +### Request Parameter Handling + +FastMCP carefully handles different types of parameters in OpenAPI requests: + +#### Query Parameters + +By default, FastMCP will only include query parameters that have non-empty values. Parameters with `None` values or empty strings (`""`) are automatically filtered out of requests. This ensures that API servers don't receive unnecessary empty parameters that might cause issues. + +For example, if you call a tool with these parameters: +```python +await client.call_tool("search_products", { + "category": "electronics", # Will be included + "min_price": 100, # Will be included + "max_price": None, # Will be excluded + "brand": "", # Will be excluded +}) +``` + +The resulting HTTP request will only include `category=electronics&min_price=100`. + +#### Path Parameters + +For path parameters, which are typically required by REST APIs, FastMCP filters out `None` values and checks that all required path parameters are provided. If a required path parameter is missing or `None`, an error will be raised. + +```python +# This will work +await client.call_tool("get_product", {"product_id": 123}) + +# This will raise ValueError: "Missing required path parameters: {'product_id'}" +await client.call_tool("get_product", {"product_id": None}) +``` + +## Example: Custom Authentication + +If your API requires authentication, you can set headers on the client: + +```python +import httpx +from fastmcp import FastMCP + +# Create a client with authentication +api_client = httpx.AsyncClient( + base_url="https://api.example.com", + headers={"Authorization": "Bearer YOUR_TOKEN"} +) + +# Create an MCP server from your OpenAPI spec +mcp = FastMCP.from_openapi(openapi_spec=spec, client=api_client) +``` diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py index ed3e21aa1..9af330f35 100644 --- a/src/fastmcp/server/openapi.py +++ b/src/fastmcp/server/openapi.py @@ -53,6 +53,10 @@ class MCPType(enum.Enum): EXCLUDE = "EXCLUDE" +# Type for component naming function +ComponentNameFn = Callable[[openapi.HTTPRoute, MCPType, str], str] + + # Keep RouteType as an alias to MCPType for backward compatibility class RouteType(enum.Enum): """ @@ -65,30 +69,7 @@ class RouteType(enum.Enum): RESOURCE = "RESOURCE" RESOURCE_TEMPLATE = "RESOURCE_TEMPLATE" PROMPT = "PROMPT" - EXCLUDE = "EXCLUDE" - IGNORE = "IGNORE" # Deprecated, use EXCLUDE instead - - def __new__(cls, value): - # Deprecated in 2.4.1 - warnings.warn( - "RouteType is deprecated and will be removed in a future version. " - "Use MCPType instead.", - DeprecationWarning, - stacklevel=2, - ) - - # Add a specific warning for the deprecated IGNORE value - if value == "IGNORE": - warnings.warn( - "RouteType.IGNORE is deprecated and will be removed in a future version. " - "Use MCPType.EXCLUDE instead.", - DeprecationWarning, - stacklevel=2, - ) - - instance = object.__new__(cls) - instance._value_ = value - return instance + IGNORE = "IGNORE" @dataclass @@ -102,7 +83,7 @@ class RouteMap: def __post_init__(self): """Validate and process the route map after initialization.""" - # Handle backward compatibility for route_type + # Handle backward compatibility for route_type, deprecated in 2.5.0 if self.mcp_type is None and self.route_type is not None: warnings.warn( "The 'route_type' parameter is deprecated and will be removed in a future version. " @@ -110,7 +91,13 @@ class RouteMap: DeprecationWarning, stacklevel=2, ) - + if isinstance(self.route_type, RouteType): + warnings.warn( + "The RouteType class is deprecated and will be removed in a future version. " + "Use MCPType instead.", + DeprecationWarning, + stacklevel=2, + ) # Check for the deprecated IGNORE value if self.route_type == RouteType.IGNORE: warnings.warn( @@ -236,13 +223,6 @@ def _determine_route_type( return MCPType.TOOL -# Placeholder function to provide function metadata -async def _openapi_passthrough(*args, **kwargs): - """Placeholder function for OpenAPI endpoints.""" - # This is kept for metadata generation purposes - pass - - class OpenAPITool(Tool): """Tool implementation for OpenAPI endpoints.""" @@ -670,6 +650,55 @@ class OpenAPIResourceTemplate(ResourceTemplate): ) +def default_component_name_fn( + route: openapi.HTTPRoute, mcp_type: MCPType, default_name: str +) -> str: + """ + Default function for generating component names from routes. + + This function creates simpler names than the original method: + - For resources and templates: Just uses the resource name without HTTP method + - For tools: Uses a simpler naming convention + + Args: + route: The OpenAPI route + mcp_type: The component type being created + default_name: The original default name that would be used + + Returns: + str: The component name to use + """ + # First check for OpenAPI operationId which takes precedence + if route.operation_id: + return route.operation_id + + # For path-based naming, clean up the path + path_parts = route.path.strip("/").split("/") + + # Remove path parameters (parts with {}) + clean_parts = [] + for part in path_parts: + if part.startswith("{") and part.endswith("}"): + # For templates, include parameter name without braces + if mcp_type == MCPType.RESOURCE_TEMPLATE: + param_name = part[1:-1] # Remove braces + clean_parts.append(param_name) + else: + clean_parts.append(part) + + # Join the parts + resource_name = "_".join(clean_parts) + + # For tools, might be useful to keep the method for clarity on what it does + if mcp_type == MCPType.TOOL: + # Only include method if it helps distinguish (POST, PUT, PATCH, DELETE) + # For GET we don't need the method as it's implied for resources + if route.method != "GET": + resource_name = f"{route.method.lower()}_{resource_name}" + + return resource_name + + class FastMCPOpenAPI(FastMCP): """ FastMCP server implementation that creates components from an OpenAPI schema. @@ -715,6 +744,7 @@ class FastMCPOpenAPI(FastMCP): name: str | None = None, route_maps: list[RouteMap] | None = None, timeout: float | None = None, + component_namer: ComponentNameFn | None = None, **settings: Any, ): """ @@ -726,12 +756,18 @@ class FastMCPOpenAPI(FastMCP): name: Optional name for the server route_maps: Optional list of RouteMap objects defining route mappings timeout: Optional timeout (in seconds) for all requests + component_namer: Optional function to customize component names **settings: Additional settings for FastMCP """ super().__init__(name=name or "OpenAPI FastMCP", **settings) self._client = client self._timeout = timeout + self._component_namer = component_namer or default_component_name_fn + + # Keep track of names to detect collisions + self._used_names = {"tools": set(), "resources": set(), "templates": set()} + http_routes = openapi.parse_openapi_to_http_routes(openapi_spec) # Process routes @@ -740,20 +776,18 @@ class FastMCPOpenAPI(FastMCP): # Determine route type based on mappings or default rules route_type = _determine_route_type(route, route_maps) - # Use operation_id if available, otherwise generate a name - operation_id = route.operation_id - if not operation_id: - # Generate operation ID from method and path - path_parts = route.path.strip("/").split("/") - path_name = "_".join(p for p in path_parts if not p.startswith("{")) - operation_id = f"{route.method.lower()}_{path_name}" + # Generate a default name from the route + default_name = self._generate_default_name(route) + + # Get the component name using the namer function + component_name = self._component_namer(route, route_type, default_name) if route_type == MCPType.TOOL: - self._create_openapi_tool(route, operation_id) + self._create_openapi_tool(route, component_name) elif route_type == MCPType.RESOURCE: - self._create_openapi_resource(route, operation_id) + self._create_openapi_resource(route, component_name) elif route_type == MCPType.RESOURCE_TEMPLATE: - self._create_openapi_template(route, operation_id) + self._create_openapi_template(route, component_name) elif route_type == MCPType.PROMPT: # Not implemented yet logger.warning( @@ -764,10 +798,59 @@ class FastMCPOpenAPI(FastMCP): logger.info(f"Created FastMCP OpenAPI server with {len(http_routes)} routes") - def _create_openapi_tool(self, route: openapi.HTTPRoute, operation_id: str): + def _generate_default_name(self, route: openapi.HTTPRoute) -> str: + """Generate a default name from the route path.""" + # Use OpenAPI operationId if available + if route.operation_id: + return route.operation_id + + # Generate a name from the path + path_parts = route.path.strip("/").split("/") + path_name = "_".join(p for p in path_parts if not p.startswith("{")) + + # The original default naming included the HTTP method + return f"{route.method.lower()}_{path_name}" + + def _get_unique_name( + self, name: str, component_type: Literal["tools", "resources", "templates"] + ) -> str: + """ + Ensure the name is unique within its component type by appending numbers if needed. + + Args: + name: The proposed name + component_type: The type of component ("tools", "resources", or "templates") + + Returns: + str: A unique name for the component + """ + # Check if the name is already used + if name not in self._used_names[component_type]: + self._used_names[component_type].add(name) + return name + + # Find the next available number suffix + counter = 2 + while f"{name}_{counter}" in self._used_names[component_type]: + counter += 1 + + # Create the new name + new_name = f"{name}_{counter}" + logger.debug( + f"Name collision detected: '{name}' already exists as a {component_type[:-1]}. " + f"Using '{new_name}' instead." + ) + + self._used_names[component_type].add(new_name) + return new_name + + def _create_openapi_tool(self, route: openapi.HTTPRoute, name: str): """Creates and registers an OpenAPITool with enhanced description.""" combined_schema = _combine_schemas(route) - tool_name = operation_id + + # Get a unique tool name + tool_name = self._get_unique_name(name, "tools") + base_description = ( route.description or route.summary @@ -797,9 +880,11 @@ class FastMCPOpenAPI(FastMCP): f"Registered TOOL: {tool_name} ({route.method} {route.path}) with tags: {route.tags}" ) - def _create_openapi_resource(self, route: openapi.HTTPRoute, operation_id: str): + def _create_openapi_resource(self, route: openapi.HTTPRoute, name: str): """Creates and registers an OpenAPIResource with enhanced description.""" - resource_name = operation_id + # Get a unique resource name + resource_name = self._get_unique_name(name, "resources") + resource_uri = f"resource://openapi/{resource_name}" base_description = ( route.description or route.summary or f"Represents {route.path}" @@ -828,9 +913,11 @@ class FastMCPOpenAPI(FastMCP): f"Registered RESOURCE: {resource_uri} ({route.method} {route.path}) with tags: {route.tags}" ) - def _create_openapi_template(self, route: openapi.HTTPRoute, operation_id: str): + def _create_openapi_template(self, route: openapi.HTTPRoute, name: str): """Creates and registers an OpenAPIResourceTemplate with enhanced description.""" - template_name = operation_id + # Get a unique template name + template_name = self._get_unique_name(name, "templates") + path_params = [p.name for p in route.parameters if p.location == "path"] path_params.sort() # Sort for consistent URIs diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 389ad9600..70fe8dbb0 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -1147,19 +1147,22 @@ class FastMCP(Generic[LifespanResultT]): """ Create a FastMCP server from an OpenAPI specification. """ - from .openapi import FastMCPOpenAPI, RouteMap, RouteType + from .openapi import ALL_TOOLS, FastMCPOpenAPI + + # Deprecated since 2.5.0 + if all_routes_as_tools: + warnings.warn( + "The 'all_routes_as_tools' parameter is deprecated and will be removed in a future version. " + "Use 'route_maps=[ALL_TOOLS()]' instead.", + DeprecationWarning, + stacklevel=2, + ) if all_routes_as_tools and route_maps: raise ValueError("Cannot specify both all_routes_as_tools and route_maps") elif all_routes_as_tools: - route_maps = [ - RouteMap( - methods="*", - pattern=r".*", - route_type=RouteType.TOOL, - ) - ] + route_maps = [ALL_TOOLS()] return FastMCPOpenAPI( openapi_spec=openapi_spec, @@ -1181,15 +1184,21 @@ class FastMCP(Generic[LifespanResultT]): Create a FastMCP server from a FastAPI application. """ - from .openapi import FastMCPOpenAPI, RouteMap, RouteType + from .openapi import ALL_TOOLS, FastMCPOpenAPI + + if all_routes_as_tools: + warnings.warn( + "The 'all_routes_as_tools' parameter is deprecated and will be removed in a future version. " + "Use 'route_maps=[ALL_TOOLS()]' instead.", + DeprecationWarning, + stacklevel=2, + ) if all_routes_as_tools and route_maps: raise ValueError("Cannot specify both all_routes_as_tools and route_maps") elif all_routes_as_tools: - route_maps = [ - RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL) - ] + route_maps = [ALL_TOOLS()] client = httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://fastapi" diff --git a/tests/server/test_openapi.py b/tests/server/test_openapi.py index f38ae4acc..4dfb721e0 100644 --- a/tests/server/test_openapi.py +++ b/tests/server/test_openapi.py @@ -18,11 +18,11 @@ from fastmcp.client import Client from fastmcp.exceptions import ToolError from fastmcp.server.openapi import ( FastMCPOpenAPI, + MCPType, OpenAPIResource, OpenAPIResourceTemplate, OpenAPITool, RouteMap, - RouteType, ) @@ -304,7 +304,7 @@ class TestTools: openapi_spec=openapi_spec, client=api_client, route_maps=[ - RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.TOOL) + RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.TOOL) ], ) async with Client(mcp_server) as client: @@ -956,9 +956,7 @@ async def test_empty_query_parameters_not_sent( mcp_server = FastMCPOpenAPI( openapi_spec=openapi_spec, client=api_client, - route_maps=[ - RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.TOOL) - ], + route_maps=[RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.TOOL)], ) # Call the search tool with mixed parameter values @@ -1499,17 +1497,15 @@ class TestFastAPIDescriptionPropagation: # Create custom route mappings route_maps = [ # Map GET /items to Resource - RouteMap( - methods=["GET"], pattern=r"^/items$", route_type=RouteType.RESOURCE - ), + RouteMap(methods=["GET"], pattern=r"^/items$", mcp_type=MCPType.RESOURCE), # Map GET /items/{item_id} to ResourceTemplate RouteMap( methods=["GET"], pattern=r"^/items/\{.*\}$", - route_type=RouteType.RESOURCE_TEMPLATE, + mcp_type=MCPType.RESOURCE_TEMPLATE, ), # Map POST /items to Tool - RouteMap(methods=["POST"], pattern=r"^/items$", route_type=RouteType.TOOL), + RouteMap(methods=["POST"], pattern=r"^/items$", mcp_type=MCPType.TOOL), ] # Create FastMCP server with the OpenAPI spec and custom route mappings @@ -1918,7 +1914,7 @@ class TestRouteMapWildcard: ): """Test that a RouteMap with methods='*' matches all HTTP methods.""" # Create a single route map with wildcard method - route_maps = [RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL)] + route_maps = [RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)] mcp = FastMCPOpenAPI( openapi_spec=basic_openapi_spec, @@ -1947,9 +1943,9 @@ class TestRouteMapWildcard: # Create route maps with specific method first, then wildcard route_maps = [ # GET operations should be mapped to resources - RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE), + RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE), # All other operations should be mapped to tools - RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL), + RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL), ] mcp = FastMCPOpenAPI( @@ -1977,9 +1973,9 @@ class TestRouteMapWildcard: # Create route maps with wildcard first, then specific methods route_maps = [ # Wildcard first matches everything - RouteMap(methods="*", pattern=r".*", route_type=RouteType.TOOL), + RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL), # This should never be reached - RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE), + RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE), ] mcp = FastMCPOpenAPI( @@ -2002,9 +1998,9 @@ class TestRouteMapWildcard: """Test wildcard methods combined with specific path patterns.""" route_maps = [ # All methods on /users path -> Resources - RouteMap(methods="*", pattern=r".*/users$", route_type=RouteType.RESOURCE), + RouteMap(methods="*", pattern=r".*/users$", mcp_type=MCPType.RESOURCE), # All methods on /posts path -> Tools - RouteMap(methods="*", pattern=r".*/posts$", route_type=RouteType.TOOL), + RouteMap(methods="*", pattern=r".*/posts$", mcp_type=MCPType.TOOL), ] mcp = FastMCPOpenAPI( @@ -2063,95 +2059,104 @@ class TestAllRoutesAsTools: async def test_from_openapi_all_routes_as_tools(self, simple_api_spec, mock_client): """Test FastMCP.from_openapi with all_routes_as_tools=True.""" - # Create server with all routes as tools - server = FastMCP.from_openapi( - openapi_spec=simple_api_spec, client=mock_client, all_routes_as_tools=True - ) - # All operations (GET and POST) should be mapped to tools - tools = server._tool_manager.list_tools() - tool_names = {t.name for t in tools} + with pytest.warns(DeprecationWarning, match="all_routes_as_tools.*deprecated"): + server = FastMCP.from_openapi( + openapi_spec=simple_api_spec, + client=mock_client, + all_routes_as_tools=True, + ) - assert "getItems" in tool_names - assert "createItem" in tool_names - assert len(tools) == 2 + # Check that all routes are tools + tools = await server.get_tools() + assert len(tools) >= 2 # Should have at least the two endpoints as tools - # No resources or templates should be created - resources = server._resource_manager.get_resources() - templates = server._resource_manager.get_templates() + # Should have no resources since all routes are tools + resources = await server.get_resources() assert len(resources) == 0 + + # Should have no resource templates since all routes are tools + templates = await server.get_resource_templates() assert len(templates) == 0 async def test_from_openapi_all_routes_as_tools_conflicting_args( self, simple_api_spec, mock_client ): """Test FastMCP.from_openapi raises error when both route_maps and all_routes_as_tools are provided.""" - # Try to create server with conflicting args with pytest.raises( ValueError, match="Cannot specify both all_routes_as_tools and route_maps" ): - FastMCP.from_openapi( - openapi_spec=simple_api_spec, - client=mock_client, - all_routes_as_tools=True, - route_maps=[ - RouteMap( - methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE - ) - ], - ) + with pytest.warns( + DeprecationWarning, match="all_routes_as_tools.*deprecated" + ): + FastMCP.from_openapi( + openapi_spec=simple_api_spec, + client=mock_client, + route_maps=[ + RouteMap( + methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE + ) + ], + all_routes_as_tools=True, + ) async def test_from_fastapi_all_routes_as_tools(self): """Test FastMCP.from_fastapi with all_routes_as_tools=True.""" - # Create a simple FastAPI app - app = FastAPI(title="Test FastAPI") + + try: + import fastapi + except ImportError: + pytest.skip("FastAPI not available") + + app = fastapi.FastAPI() @app.get("/items") - async def get_items(): - return [{"id": 1, "name": "Item 1"}] + def get_items(): + return {"items": []} @app.post("/items") - async def create_item(item: dict): - return {"id": 2, **item} + def create_item(): + return {"item": "created"} - # Create server with all routes as tools - server = FastMCP.from_fastapi(app=app, all_routes_as_tools=True) + with pytest.warns(DeprecationWarning, match="all_routes_as_tools.*deprecated"): + server = FastMCP.from_fastapi(app=app, all_routes_as_tools=True) - # Both GET and POST operations should be mapped to tools - tools = server._tool_manager.list_tools() + # Check that all routes are tools + tools = await server.get_tools() + assert len(tools) >= 2 # Should have at least the two endpoints as tools - # Get tool names from the generated operation IDs - tool_names = {t.name for t in tools} - - # Check that both routes were mapped to tools - # The exact names depend on FastAPI's operation ID generation - assert len(tools) == 2 - assert any("get" in name.lower() for name in tool_names) - assert any("post" in name.lower() for name in tool_names) - - # No resources or templates should be created - resources = server._resource_manager.get_resources() - templates = server._resource_manager.get_templates() + # Should have no resources since all routes are tools + resources = await server.get_resources() assert len(resources) == 0 + + # Should have no resource templates since all routes are tools + templates = await server.get_resource_templates() assert len(templates) == 0 async def test_from_fastapi_all_routes_as_tools_conflicting_args(self): """Test FastMCP.from_fastapi raises error when both route_maps and all_routes_as_tools are provided.""" - app = FastAPI(title="Test FastAPI") + try: + import fastapi + except ImportError: + pytest.skip("FastAPI not available") + + app = fastapi.FastAPI() - # Try to create server with conflicting args with pytest.raises( ValueError, match="Cannot specify both all_routes_as_tools and route_maps" ): - FastMCP.from_fastapi( - app=app, - all_routes_as_tools=True, - route_maps=[ - RouteMap( - methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE - ) - ], - ) + with pytest.warns( + DeprecationWarning, match="all_routes_as_tools.*deprecated" + ): + FastMCP.from_fastapi( + app=app, + route_maps=[ + RouteMap( + methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE + ) + ], + all_routes_as_tools=True, + ) class TestRouteTypeExclude: @@ -2202,10 +2207,10 @@ class TestRouteTypeExclude: RouteMap( methods=["GET"], pattern=r"^/analytics$", - route_type=RouteType.IGNORE, + mcp_type=MCPType.EXCLUDE, ), # Make everything else a resource - RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.RESOURCE), + RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE), ], ) diff --git a/tests/server/test_openapi_naming.py b/tests/server/test_openapi_naming.py new file mode 100644 index 000000000..52ffe00e3 --- /dev/null +++ b/tests/server/test_openapi_naming.py @@ -0,0 +1,231 @@ +"""Tests for OpenAPI component naming in FastMCP.""" + +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +from fastmcp.server.openapi import FastMCPOpenAPI, MCPType + + +@pytest.fixture +def simple_openapi_spec(): + """A simple OpenAPI spec with some routes for testing.""" + return { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "paths": { + "/users": { + "get": { + "summary": "Get all users", + "responses": {"200": {"description": "OK"}}, + }, + "post": { + "summary": "Create a user", + "responses": {"201": {"description": "Created"}}, + }, + }, + "/users/{id}": { + "get": { + "summary": "Get a user", + "parameters": [ + { + "name": "id", + "in": "path", + "required": True, + "schema": {"type": "string"}, + } + ], + "responses": {"200": {"description": "OK"}}, + }, + "put": { + "summary": "Update a user", + "parameters": [ + { + "name": "id", + "in": "path", + "required": True, + "schema": {"type": "string"}, + } + ], + "responses": {"200": {"description": "OK"}}, + }, + }, + "/users/{id}/orders": { + "get": { + "summary": "Get user orders", + "parameters": [ + { + "name": "id", + "in": "path", + "required": True, + "schema": {"type": "string"}, + } + ], + "responses": {"200": {"description": "OK"}}, + } + }, + "/products": { + "get": { + "operationId": "listProducts", + "summary": "Get all products", + "responses": {"200": {"description": "OK"}}, + } + }, + }, + } + + +class TestOpenAPIComponentNaming: + """Tests for OpenAPI component naming functionality.""" + + @patch("fastmcp.server.openapi._combine_schemas") + def test_default_naming(self, mock_combine, simple_openapi_spec): + """Test the default component naming behavior.""" + # Mock the HTTP client + mock_client = MagicMock(spec=httpx.AsyncClient) + + # Mock the combine schemas function to return empty dict + mock_combine.return_value = {} + + # Create a server with the default naming + # Instead of mocking the creation methods, we'll just override them to + # add the names to _used_names without actually creating components + class TestServer(FastMCPOpenAPI): + def _create_openapi_tool(self, route, name): + _tool_name = self._get_unique_name(name, "tools") + # Don't actually create the tool, just record that the name was used + + def _create_openapi_resource(self, route, name): + _resource_name = self._get_unique_name(name, "resources") + # Don't actually create the resource, just record that the name was used + + def _create_openapi_template(self, route, name): + _template_name = self._get_unique_name(name, "templates") + # Don't actually create the template, just record that the name was used + + # Create the server with our test subclass + server = TestServer( + openapi_spec=simple_openapi_spec, + client=mock_client, + ) + + # Check that the correct names were generated + expected_names = { + "tools": {"post_users", "put_users"}, + "resources": { + "users", + "listProducts", + }, # GET /users, GET /products (from operationId) + "templates": { + "users_id", + "users_id_orders", + }, # GET /users/{id}, GET /users/{id}/orders + } + + # The "tools" set in the server might contain more than our expected names + # because all HTTP methods could be converted to tools - we just check for inclusion + assert expected_names["tools"].issubset(server._used_names["tools"]) + assert expected_names["resources"].issubset(server._used_names["resources"]) + assert expected_names["templates"].issubset(server._used_names["templates"]) + + # Check that the operationId is preferred for naming + assert "listProducts" in server._used_names["resources"] + + @patch("fastmcp.server.openapi._combine_schemas") + def test_custom_naming(self, mock_combine, simple_openapi_spec): + """Test custom component naming function.""" + # Mock the HTTP client + mock_client = MagicMock(spec=httpx.AsyncClient) + + # Mock the combine schemas function to return empty dict + mock_combine.return_value = {} + + # Create a custom naming function + def custom_namer(route, mcp_type, default_name): + # Always prefix with component type + if mcp_type == MCPType.TOOL: + prefix = "tool" + elif mcp_type == MCPType.RESOURCE: + prefix = "res" + elif mcp_type == MCPType.RESOURCE_TEMPLATE: + prefix = "tmpl" + else: + prefix = "other" + + # Use operationId if available + if route.operation_id: + return f"{prefix}_{route.operation_id}" + + # Otherwise use the path + path_name = route.path.replace("/", "_").replace("{", "").replace("}", "") + return f"{prefix}{path_name}" + + # Create a custom testing server subclass + class TestServer(FastMCPOpenAPI): + def _create_openapi_tool(self, route, name): + _tool_name = self._get_unique_name(name, "tools") + # Don't actually create the tool, just record that the name was used + + def _create_openapi_resource(self, route, name): + _resource_name = self._get_unique_name(name, "resources") + # Don't actually create the resource, just record that the name was used + + def _create_openapi_template(self, route, name): + _template_name = self._get_unique_name(name, "templates") + # Don't actually create the template, just record that the name was used + + # Create a server with the custom naming + server = TestServer( + openapi_spec=simple_openapi_spec, + client=mock_client, + component_namer=custom_namer, + ) + + # Check some of the generated names + assert "tool_users" in server._used_names["tools"] + assert "res_users" in server._used_names["resources"] + assert "tmpl_users_id" in server._used_names["templates"] + assert "res_listProducts" in server._used_names["resources"] + + @patch("fastmcp.server.openapi._combine_schemas") + def test_collision_handling(self, mock_combine, simple_openapi_spec): + """Test how name collisions are handled by appending numbers.""" + # Mock the HTTP client + mock_client = MagicMock(spec=httpx.AsyncClient) + + # Mock the combine schemas function to return empty dict + mock_combine.return_value = {} + + # Create a custom naming function that always returns the same name + def collision_namer(route, mcp_type, default_name): + return "same_name" + + # Create a custom testing server subclass + class TestServer(FastMCPOpenAPI): + def _create_openapi_tool(self, route, name): + _tool_name = self._get_unique_name(name, "tools") + # Don't actually create the tool, just record that the name was used + + def _create_openapi_resource(self, route, name): + _resource_name = self._get_unique_name(name, "resources") + # Don't actually create the resource, just record that the name was used + + def _create_openapi_template(self, route, name): + _template_name = self._get_unique_name(name, "templates") + # Don't actually create the template, just record that the name was used + + # Create a server with the collision namer + server = TestServer( + openapi_spec=simple_openapi_spec, + client=mock_client, + component_namer=collision_namer, + ) + + # Check that names were renamed with numbers + assert "same_name" in server._used_names["tools"] + assert "same_name_2" in server._used_names["tools"] + assert "same_name" in server._used_names["resources"] + assert "same_name_2" in server._used_names["resources"] + assert "same_name" in server._used_names["templates"] + assert "same_name_2" in server._used_names["templates"] diff --git a/tests/server/test_openapi_path_parameters.py b/tests/server/test_openapi_path_parameters.py index 9940594a8..517382a2c 100644 --- a/tests/server/test_openapi_path_parameters.py +++ b/tests/server/test_openapi_path_parameters.py @@ -6,7 +6,7 @@ import pytest from fastapi import FastAPI, Query from fastmcp import Client, FastMCP -from fastmcp.server.openapi import OpenAPITool, RouteMap, RouteType +from fastmcp.server.openapi import MCPType, OpenAPITool, RouteMap from fastmcp.utilities.openapi import HTTPRoute, ParameterInfo @@ -286,9 +286,7 @@ async def test_array_query_param_with_fastapi(): # Create a FastMCP server from the FastAPI app mcp = FastMCP.from_fastapi( app, - route_maps=[ - RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.TOOL) - ], + route_maps=[RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.TOOL)], ) # Test with the client diff --git a/tests/server/test_route_map_shortcuts.py b/tests/server/test_route_map_shortcuts.py index a64be9910..92cc5465f 100644 --- a/tests/server/test_route_map_shortcuts.py +++ b/tests/server/test_route_map_shortcuts.py @@ -11,7 +11,6 @@ from fastmcp.server.openapi import ( FastMCPOpenAPI, MCPType, RouteMap, - RouteType, ) @@ -52,6 +51,8 @@ class TestRouteMapShortcuts: def test_backward_compatibility(self): """Test that backward compatibility with RouteType and route_type works.""" + from fastmcp.server.openapi import RouteType + # Test creating a RouteMap with route_type with pytest.warns(DeprecationWarning): route_map = RouteMap( From 2a65c0848e8815a50135ce266b515a46c36807f3 Mon Sep 17 00:00:00 2001 From: davenpi Date: Thu, 22 May 2025 17:52:39 -0400 Subject: [PATCH 066/114] Feat(client): add cancel notification method --- src/fastmcp/client/client.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 19552ea35..f6333fb08 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -210,6 +210,23 @@ class Client: result = await self.session.send_ping() return isinstance(result, mcp.types.EmptyResult) + async def cancel( + self, + request_id: str | int, + reason: str | None = None, + ) -> None: + """Send a cancellation notification for an in-progress request.""" + notification = mcp.types.ClientNotification( + mcp.types.CancelledNotification( + method="notifications/cancelled", + params=mcp.types.CancelledNotificationParams( + requestId=request_id, + reason=reason, + ), + ) + ) + await self.session.send_notification(notification) + async def progress( self, progress_token: str | int, From 702412e28b0b197502788a73cd652e4e2bbab783 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 22 May 2025 21:11:46 -0400 Subject: [PATCH 067/114] Remove custom names --- docs/patterns/openapi.mdx | 45 +--- src/fastmcp/server/openapi.py | 94 +++---- tests/server/{ => openapi}/test_openapi.py | 0 .../test_openapi_path_parameters.py | 0 tests/server/test_openapi_naming.py | 231 ------------------ 5 files changed, 29 insertions(+), 341 deletions(-) rename tests/server/{ => openapi}/test_openapi.py (100%) rename tests/server/{ => openapi}/test_openapi_path_parameters.py (100%) delete mode 100644 tests/server/test_openapi_naming.py diff --git a/docs/patterns/openapi.mdx b/docs/patterns/openapi.mdx index 68d5c6bd4..c2a586ccd 100644 --- a/docs/patterns/openapi.mdx +++ b/docs/patterns/openapi.mdx @@ -41,52 +41,10 @@ mcp = FastMCP.from_openapi( ) ``` -### Component Naming +## Route Mapping -You can customize how FastMCP names the components generated from your OpenAPI spec: - -```python -# Custom naming function -def my_component_namer(route, mcp_type, default_name): - # Create custom names based on the route and component type - if route.operation_id: - return route.operation_id - - # For example, prefix with component type - prefix = { - MCPType.TOOL: "tool_", - MCPType.RESOURCE: "resource_", - MCPType.RESOURCE_TEMPLATE: "template_", - }.get(mcp_type, "") - - path_name = route.path.replace("/", "_").strip("_") - return f"{prefix}{path_name}" - -mcp = FastMCP.from_openapi( - openapi_spec=spec, - client=api_client, - component_namer=my_component_namer -) -``` - -By default, FastMCP generates component names as follows: - -- If the route has an `operationId` in the OpenAPI spec, that is used -- Otherwise, the name is generated from the route path: - - For `GET` routes mapped to resources: Just the resource name (e.g., `/users` → `users`) - - For routes with path parameters mapped to templates: The path with parameter names (e.g., `/users/{id}` → `users_id`) - - For other methods mapped to tools: Method + resource name (e.g., `POST /users` → `post_users`) - -#### Handling Name Collisions - -When multiple routes would generate the same component name, FastMCP automatically appends a number suffix to ensure uniqueness (e.g., `users`, `users_2`, `users_3`). You'll see these numbered suffixes in the component names returned by `get_tools()`, `get_resources()`, etc. - -If you need more control over naming, you can provide a custom `component_namer` function that handles potential collisions in your own way. - -### Route Mapping - By default, OpenAPI routes are mapped to MCP components based on these rules: | OpenAPI Route | Example |MCP Component | Notes | @@ -232,7 +190,6 @@ mcp = FastMCPOpenAPI( #### Route Map Shortcuts - FastMCP provides several shortcut functions to create common route maps more easily: diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py index 9af330f35..0bed5c537 100644 --- a/src/fastmcp/server/openapi.py +++ b/src/fastmcp/server/openapi.py @@ -53,10 +53,6 @@ class MCPType(enum.Enum): EXCLUDE = "EXCLUDE" -# Type for component naming function -ComponentNameFn = Callable[[openapi.HTTPRoute, MCPType, str], str] - - # Keep RouteType as an alias to MCPType for backward compatibility class RouteType(enum.Enum): """ @@ -650,55 +646,6 @@ class OpenAPIResourceTemplate(ResourceTemplate): ) -def default_component_name_fn( - route: openapi.HTTPRoute, mcp_type: MCPType, default_name: str -) -> str: - """ - Default function for generating component names from routes. - - This function creates simpler names than the original method: - - For resources and templates: Just uses the resource name without HTTP method - - For tools: Uses a simpler naming convention - - Args: - route: The OpenAPI route - mcp_type: The component type being created - default_name: The original default name that would be used - - Returns: - str: The component name to use - """ - # First check for OpenAPI operationId which takes precedence - if route.operation_id: - return route.operation_id - - # For path-based naming, clean up the path - path_parts = route.path.strip("/").split("/") - - # Remove path parameters (parts with {}) - clean_parts = [] - for part in path_parts: - if part.startswith("{") and part.endswith("}"): - # For templates, include parameter name without braces - if mcp_type == MCPType.RESOURCE_TEMPLATE: - param_name = part[1:-1] # Remove braces - clean_parts.append(param_name) - else: - clean_parts.append(part) - - # Join the parts - resource_name = "_".join(clean_parts) - - # For tools, might be useful to keep the method for clarity on what it does - if mcp_type == MCPType.TOOL: - # Only include method if it helps distinguish (POST, PUT, PATCH, DELETE) - # For GET we don't need the method as it's implied for resources - if route.method != "GET": - resource_name = f"{route.method.lower()}_{resource_name}" - - return resource_name - - class FastMCPOpenAPI(FastMCP): """ FastMCP server implementation that creates components from an OpenAPI schema. @@ -744,7 +691,6 @@ class FastMCPOpenAPI(FastMCP): name: str | None = None, route_maps: list[RouteMap] | None = None, timeout: float | None = None, - component_namer: ComponentNameFn | None = None, **settings: Any, ): """ @@ -756,14 +702,12 @@ class FastMCPOpenAPI(FastMCP): name: Optional name for the server route_maps: Optional list of RouteMap objects defining route mappings timeout: Optional timeout (in seconds) for all requests - component_namer: Optional function to customize component names **settings: Additional settings for FastMCP """ super().__init__(name=name or "OpenAPI FastMCP", **settings) self._client = client self._timeout = timeout - self._component_namer = component_namer or default_component_name_fn # Keep track of names to detect collisions self._used_names = {"tools": set(), "resources": set(), "templates": set()} @@ -777,10 +721,7 @@ class FastMCPOpenAPI(FastMCP): route_type = _determine_route_type(route, route_maps) # Generate a default name from the route - default_name = self._generate_default_name(route) - - # Get the component name using the namer function - component_name = self._component_namer(route, route_type, default_name) + component_name = self._generate_default_name(route, route_type) if route_type == MCPType.TOOL: self._create_openapi_tool(route, component_name) @@ -798,18 +739,39 @@ class FastMCPOpenAPI(FastMCP): logger.info(f"Created FastMCP OpenAPI server with {len(http_routes)} routes") - def _generate_default_name(self, route: openapi.HTTPRoute) -> str: + def _generate_default_name( + self, route: openapi.HTTPRoute, mcp_type: MCPType + ) -> str: """Generate a default name from the route path.""" - # Use OpenAPI operationId if available + # First check for OpenAPI operationId which takes precedence if route.operation_id: return route.operation_id - # Generate a name from the path + # For path-based naming, clean up the path path_parts = route.path.strip("/").split("/") - path_name = "_".join(p for p in path_parts if not p.startswith("{")) - # The original default naming included the HTTP method - return f"{route.method.lower()}_{path_name}" + # Remove path parameters (parts with {}) + clean_parts = [] + for part in path_parts: + if part.startswith("{") and part.endswith("}"): + # For templates, include parameter name without braces + if mcp_type == MCPType.RESOURCE_TEMPLATE: + param_name = part[1:-1] # Remove braces + clean_parts.append(param_name) + else: + clean_parts.append(part) + + # Join the parts + resource_name = "_".join(clean_parts) + + # For tools, might be useful to keep the method for clarity on what it does + if mcp_type == MCPType.TOOL: + # Only include method if it helps distinguish (POST, PUT, PATCH, DELETE) + # For GET we don't need the method as it's implied for resources + if route.method != "GET": + resource_name = f"{route.method.lower()}_{resource_name}" + + return resource_name def _get_unique_name( self, name: str, component_type: Literal["tools", "resources", "templates"] diff --git a/tests/server/test_openapi.py b/tests/server/openapi/test_openapi.py similarity index 100% rename from tests/server/test_openapi.py rename to tests/server/openapi/test_openapi.py diff --git a/tests/server/test_openapi_path_parameters.py b/tests/server/openapi/test_openapi_path_parameters.py similarity index 100% rename from tests/server/test_openapi_path_parameters.py rename to tests/server/openapi/test_openapi_path_parameters.py diff --git a/tests/server/test_openapi_naming.py b/tests/server/test_openapi_naming.py deleted file mode 100644 index 52ffe00e3..000000000 --- a/tests/server/test_openapi_naming.py +++ /dev/null @@ -1,231 +0,0 @@ -"""Tests for OpenAPI component naming in FastMCP.""" - -from unittest.mock import MagicMock, patch - -import httpx -import pytest - -from fastmcp.server.openapi import FastMCPOpenAPI, MCPType - - -@pytest.fixture -def simple_openapi_spec(): - """A simple OpenAPI spec with some routes for testing.""" - return { - "openapi": "3.0.0", - "info": {"title": "Test API", "version": "1.0.0"}, - "paths": { - "/users": { - "get": { - "summary": "Get all users", - "responses": {"200": {"description": "OK"}}, - }, - "post": { - "summary": "Create a user", - "responses": {"201": {"description": "Created"}}, - }, - }, - "/users/{id}": { - "get": { - "summary": "Get a user", - "parameters": [ - { - "name": "id", - "in": "path", - "required": True, - "schema": {"type": "string"}, - } - ], - "responses": {"200": {"description": "OK"}}, - }, - "put": { - "summary": "Update a user", - "parameters": [ - { - "name": "id", - "in": "path", - "required": True, - "schema": {"type": "string"}, - } - ], - "responses": {"200": {"description": "OK"}}, - }, - }, - "/users/{id}/orders": { - "get": { - "summary": "Get user orders", - "parameters": [ - { - "name": "id", - "in": "path", - "required": True, - "schema": {"type": "string"}, - } - ], - "responses": {"200": {"description": "OK"}}, - } - }, - "/products": { - "get": { - "operationId": "listProducts", - "summary": "Get all products", - "responses": {"200": {"description": "OK"}}, - } - }, - }, - } - - -class TestOpenAPIComponentNaming: - """Tests for OpenAPI component naming functionality.""" - - @patch("fastmcp.server.openapi._combine_schemas") - def test_default_naming(self, mock_combine, simple_openapi_spec): - """Test the default component naming behavior.""" - # Mock the HTTP client - mock_client = MagicMock(spec=httpx.AsyncClient) - - # Mock the combine schemas function to return empty dict - mock_combine.return_value = {} - - # Create a server with the default naming - # Instead of mocking the creation methods, we'll just override them to - # add the names to _used_names without actually creating components - class TestServer(FastMCPOpenAPI): - def _create_openapi_tool(self, route, name): - _tool_name = self._get_unique_name(name, "tools") - # Don't actually create the tool, just record that the name was used - - def _create_openapi_resource(self, route, name): - _resource_name = self._get_unique_name(name, "resources") - # Don't actually create the resource, just record that the name was used - - def _create_openapi_template(self, route, name): - _template_name = self._get_unique_name(name, "templates") - # Don't actually create the template, just record that the name was used - - # Create the server with our test subclass - server = TestServer( - openapi_spec=simple_openapi_spec, - client=mock_client, - ) - - # Check that the correct names were generated - expected_names = { - "tools": {"post_users", "put_users"}, - "resources": { - "users", - "listProducts", - }, # GET /users, GET /products (from operationId) - "templates": { - "users_id", - "users_id_orders", - }, # GET /users/{id}, GET /users/{id}/orders - } - - # The "tools" set in the server might contain more than our expected names - # because all HTTP methods could be converted to tools - we just check for inclusion - assert expected_names["tools"].issubset(server._used_names["tools"]) - assert expected_names["resources"].issubset(server._used_names["resources"]) - assert expected_names["templates"].issubset(server._used_names["templates"]) - - # Check that the operationId is preferred for naming - assert "listProducts" in server._used_names["resources"] - - @patch("fastmcp.server.openapi._combine_schemas") - def test_custom_naming(self, mock_combine, simple_openapi_spec): - """Test custom component naming function.""" - # Mock the HTTP client - mock_client = MagicMock(spec=httpx.AsyncClient) - - # Mock the combine schemas function to return empty dict - mock_combine.return_value = {} - - # Create a custom naming function - def custom_namer(route, mcp_type, default_name): - # Always prefix with component type - if mcp_type == MCPType.TOOL: - prefix = "tool" - elif mcp_type == MCPType.RESOURCE: - prefix = "res" - elif mcp_type == MCPType.RESOURCE_TEMPLATE: - prefix = "tmpl" - else: - prefix = "other" - - # Use operationId if available - if route.operation_id: - return f"{prefix}_{route.operation_id}" - - # Otherwise use the path - path_name = route.path.replace("/", "_").replace("{", "").replace("}", "") - return f"{prefix}{path_name}" - - # Create a custom testing server subclass - class TestServer(FastMCPOpenAPI): - def _create_openapi_tool(self, route, name): - _tool_name = self._get_unique_name(name, "tools") - # Don't actually create the tool, just record that the name was used - - def _create_openapi_resource(self, route, name): - _resource_name = self._get_unique_name(name, "resources") - # Don't actually create the resource, just record that the name was used - - def _create_openapi_template(self, route, name): - _template_name = self._get_unique_name(name, "templates") - # Don't actually create the template, just record that the name was used - - # Create a server with the custom naming - server = TestServer( - openapi_spec=simple_openapi_spec, - client=mock_client, - component_namer=custom_namer, - ) - - # Check some of the generated names - assert "tool_users" in server._used_names["tools"] - assert "res_users" in server._used_names["resources"] - assert "tmpl_users_id" in server._used_names["templates"] - assert "res_listProducts" in server._used_names["resources"] - - @patch("fastmcp.server.openapi._combine_schemas") - def test_collision_handling(self, mock_combine, simple_openapi_spec): - """Test how name collisions are handled by appending numbers.""" - # Mock the HTTP client - mock_client = MagicMock(spec=httpx.AsyncClient) - - # Mock the combine schemas function to return empty dict - mock_combine.return_value = {} - - # Create a custom naming function that always returns the same name - def collision_namer(route, mcp_type, default_name): - return "same_name" - - # Create a custom testing server subclass - class TestServer(FastMCPOpenAPI): - def _create_openapi_tool(self, route, name): - _tool_name = self._get_unique_name(name, "tools") - # Don't actually create the tool, just record that the name was used - - def _create_openapi_resource(self, route, name): - _resource_name = self._get_unique_name(name, "resources") - # Don't actually create the resource, just record that the name was used - - def _create_openapi_template(self, route, name): - _template_name = self._get_unique_name(name, "templates") - # Don't actually create the template, just record that the name was used - - # Create a server with the collision namer - server = TestServer( - openapi_spec=simple_openapi_spec, - client=mock_client, - component_namer=collision_namer, - ) - - # Check that names were renamed with numbers - assert "same_name" in server._used_names["tools"] - assert "same_name_2" in server._used_names["tools"] - assert "same_name" in server._used_names["resources"] - assert "same_name_2" in server._used_names["resources"] - assert "same_name" in server._used_names["templates"] - assert "same_name_2" in server._used_names["templates"] From 6cac09cf2a952e1b10ce97b480d694c6dc7b3ec0 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 22 May 2025 21:59:16 -0400 Subject: [PATCH 068/114] Add support for RouteMap tags, update docs --- docs/docs.json | 3 +- docs/patterns/fastapi.mdx | 115 +----- docs/patterns/openapi.mdx | 312 ---------------- docs/servers/openapi.mdx | 250 +++++++++++++ examples/tags_example.py | 141 +++++++ src/fastmcp/server/openapi.py | 63 +--- src/fastmcp/server/server.py | 13 +- tests/server/openapi/test_openapi.py | 451 ++++++++++------------- tests/server/test_route_map_shortcuts.py | 208 ----------- 9 files changed, 605 insertions(+), 951 deletions(-) delete mode 100644 docs/patterns/openapi.mdx create mode 100644 docs/servers/openapi.mdx create mode 100644 examples/tags_example.py delete mode 100644 tests/server/test_route_map_shortcuts.py diff --git a/docs/docs.json b/docs/docs.json index cc0699edf..14facc32c 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -50,6 +50,7 @@ "servers/resources", "servers/prompts", "servers/context", + "servers/openapi", "servers/proxy", "servers/composition" ] @@ -76,8 +77,6 @@ "pages": [ "patterns/decorating-methods", "patterns/http-requests", - "patterns/openapi", - "patterns/fastapi", "patterns/contrib", "patterns/testing" ] diff --git a/docs/patterns/fastapi.mdx b/docs/patterns/fastapi.mdx index 681a4456b..09f7e298c 100644 --- a/docs/patterns/fastapi.mdx +++ b/docs/patterns/fastapi.mdx @@ -8,19 +8,18 @@ import { VersionBadge } from '/snippets/version-badge.mdx' + +**Documentation Moved**: The comprehensive FastAPI integration documentation has been moved to the [OpenAPI Integration](/patterns/openapi#fastapi-integration) page, where it's covered alongside all other OpenAPI features including route mapping and tags support. + -FastMCP can automatically convert FastAPI applications into MCP servers. +## Quick Start - -FastMCP does *not* include FastAPI as a dependency; you must install it separately to run these examples. - +FastMCP can automatically convert FastAPI applications into MCP servers: - -```python {2, 22, 25} +```python from fastapi import FastAPI from fastmcp import FastMCP - # A FastAPI app app = FastAPI() @@ -36,7 +35,6 @@ def get_item(item_id: int): def create_item(name: str): return {"id": 3, "name": name} - # Create an MCP server from your FastAPI app mcp = FastMCP.from_fastapi(app=app) @@ -44,101 +42,6 @@ if __name__ == "__main__": mcp.run() # Start the MCP server ``` -## Configuration Options - -### Timeout - -You can set a timeout for all API requests: - -```python -# Set a 5 second timeout for all requests -mcp = FastMCP.from_fastapi(app=app, timeout=5.0) -``` - -This timeout is applied to all requests made by tools, resources, and resource templates. - -## 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 [expandable] -import asyncio -from fastapi import FastAPI, HTTPException -from pydantic import BaseModel -from fastmcp import FastMCP, Client - -# Define your Pydantic model -class Item(BaseModel): - name: str - price: float - -# Create your FastAPI app -app = FastAPI() -items = {} # In-memory database - -@app.get("/items") -def list_items(): - """List all items""" - return list(items.values()) - -@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): - """Create a new item""" - item_id = len(items) + 1 - items[item_id] = {"id": item_id, **item.model_dump()} - return items[item_id] - -# Test your MCP server with a client -async def check_mcp(mcp: FastMCP): - # List the components that were created - tools = await mcp.get_tools() - resources = await mcp.get_resources() - templates = await mcp.get_resource_templates() - - print( - f"{len(tools)} Tool(s): {', '.join([t.name for t in tools.values()])}" - ) - print( - f"{len(resources)} Resource(s): {', '.join([r.name for r in resources.values()])}" - ) - print( - f"{len(templates)} Resource Template(s): {', '.join([t.name for t in templates.values()])}" - ) - - return mcp - -if __name__ == "__main__": - # Create MCP server from FastAPI app - mcp = FastMCP.from_fastapi(app=app) - - asyncio.run(check_mcp(mcp)) - - # In a real scenario, you would run the server: - mcp.run() -``` - -## Benefits - -- **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 + +For complete documentation including tag-based routing, route mapping configuration, timeout settings, authentication examples, and advanced configuration options, see the comprehensive [OpenAPI Integration documentation](/patterns/openapi#fastapi-integration). + \ No newline at end of file diff --git a/docs/patterns/openapi.mdx b/docs/patterns/openapi.mdx deleted file mode 100644 index c2a586ccd..000000000 --- a/docs/patterns/openapi.mdx +++ /dev/null @@ -1,312 +0,0 @@ ---- -title: OpenAPI Integration -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. - -```python -import httpx -from fastmcp import FastMCP - -# Create a client for your API -api_client = httpx.AsyncClient(base_url="https://api.example.com") - -# Load your OpenAPI spec -spec = {...} - -# Create an MCP server from your OpenAPI spec -mcp = FastMCP.from_openapi(openapi_spec=spec, client=api_client) - -if __name__ == "__main__": - mcp.run() -``` - -## Configuration Options - -### Timeout - -You can set a timeout for all requests by providing a `timeout` parameter (in seconds): - -```python -mcp = FastMCP.from_openapi( - openapi_spec=spec, - client=api_client, - timeout=30.0 # 30 second timeout -) -``` - -## Route Mapping - - - -By default, OpenAPI routes are mapped to MCP components based on these rules: - -| 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 | - -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".*\{.*\}.*", - mcp_type=MCPType.RESOURCE_TEMPLATE, - ), - - # GET without path parameters -> Resource - RouteMap( - methods=["GET"], - pattern=r".*", - mcp_type=MCPType.RESOURCE, - ), - - # All other methods -> Tool - ALL_TOOLS(), -] -``` - -#### 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, MCPType - -# Custom mapping rules -custom_maps = [ - # Force all analytics endpoints to be Tools - RouteMap(methods=["GET"], - pattern=r"^/analytics/.*", - mcp_type=MCPType.TOOL) -] - -# Apply custom mappings -mcp = FastMCP.from_openapi( - openapi_spec=spec, - client=api_client, - route_maps=custom_maps -) -``` - - -For backward compatibility, FastMCP still supports the `route_type` parameter and `RouteType` enum, but they are deprecated and will be removed in a future version. You will see deprecation warnings if you use them. - - -#### All Routes as Tools - -When building AI agent backends, it's often useful to treat all routes as callable tools regardless of their HTTP method. You can use the `ALL_TOOLS()` shortcut or create a custom route map: - -```python -# Make all endpoints tools using the shortcut -mcp = FastMCP.from_openapi( - openapi_spec=spec, - client=api_client, - route_maps=[ALL_TOOLS()] -) - -# Same effect using a custom route map -mcp = FastMCP.from_openapi( - openapi_spec=spec, - client=api_client, - route_maps=[ - RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL) - ] -) -``` - -#### Excluding Routes - -If you want to exclude certain routes from being converted to MCP components, you can map them to `MCPType.EXCLUDE`. This is useful for endpoints that should not be accessible to the agent. - -```python -from fastmcp.server.openapi import RouteMap, MCPType - -# Custom mapping rules to exclude specific routes -custom_maps = [ - # Exclude all admin endpoints - RouteMap( - methods="*", - pattern=r"^/admin/.*", - mcp_type=MCPType.EXCLUDE - ), - # Exclude analytics GET endpoints - RouteMap( - methods=["GET"], - pattern=r"^/analytics/.*", - mcp_type=MCPType.EXCLUDE - ) -] - -# Apply custom mappings -mcp = FastMCP.from_openapi( - openapi_spec=spec, - client=api_client, - route_maps=custom_maps -) -``` - -When a route is mapped to `MCPType.EXCLUDE`, FastMCP will log its presence but won't create any MCP component for it, effectively making it invisible to clients and agents using the MCP server. - -You can customize this behavior by providing a list of `RouteMap` objects: - -```python -from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap, MCPType - -# Custom route mappings -custom_mappings = [ - # Convert all user-related routes to tools - RouteMap( - methods=["GET", "POST", "PUT", "DELETE"], - pattern=r"^/users.*", - mcp_type=MCPType.TOOL - ), - # Exclude analytics routes - RouteMap( - methods=["*"], # All methods - pattern=r"^/analytics.*", - mcp_type=MCPType.EXCLUDE - ), -] - -# Create server with custom mappings -mcp = FastMCPOpenAPI( - openapi_spec=spec, - client=httpx.AsyncClient(), - route_maps=custom_mappings, -) -``` - -#### Route Map Shortcuts - - -FastMCP provides several shortcut functions to create common route maps more easily: - -```python -from fastmcp.server.openapi import ( - ALL_TOOLS, - EXCLUDE_ALL, - EXCLUDE_PATTERN, - PATTERN_AS_TOOLS, -) - -# Create an MCP server with custom route maps using shortcuts -mcp = FastMCP.from_openapi( - openapi_spec=spec, - client=api_client, - route_maps=[ - # First exclude all admin endpoints - EXCLUDE_PATTERN(r"^/admin/.*"), - - # Make all /api/v1 endpoints tools - PATTERN_AS_TOOLS(r"^/api/v1/.*"), - - # Make all remaining routes tools - ALL_TOOLS(), - ] -) -``` - -Available shortcuts: - -| Shortcut Function | Description | -|------------------|-------------| -| `ALL_TOOLS()` | Converts all matching routes to tools | -| `EXCLUDE_ALL()` | Excludes all matching routes from being converted to any component | -| `PATTERN_AS_TOOLS(pattern)` | Converts routes matching a specific pattern to tools | -| `EXCLUDE_PATTERN(pattern)` | Excludes routes matching a specific pattern | - -These shortcuts are particularly useful for: - -1. Converting all remaining unmatched routes to tools (use `ALL_TOOLS()`) -2. Excluding whole sections of your API (use `EXCLUDE_PATTERN("/path/.*")`) -3. Converting routes matching specific patterns to tools (use `PATTERN_AS_TOOLS("/path/.*")`) - - -You can use `EXCLUDE_ALL()` as the last entry in your custom route maps to completely ignore the default route maps. Since custom route maps are applied first and default maps are appended afterward, having `EXCLUDE_ALL()` at the end of your custom maps will match any routes that your earlier custom rules didn't match, preventing the default maps from having any effect. - -```python -# Create server that only uses custom route maps, ignoring defaults -mcp = FastMCP.from_openapi( - openapi_spec=spec, - client=api_client, - route_maps=[ - # Routes to keep as tools - PATTERN_AS_TOOLS(r"^/api/v1/.*"), - - # Exclude everything else (ignores default route maps) - EXCLUDE_ALL(), - ] -) -``` - - -## 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 - -### Request Parameter Handling - -FastMCP carefully handles different types of parameters in OpenAPI requests: - -#### Query Parameters - -By default, FastMCP will only include query parameters that have non-empty values. Parameters with `None` values or empty strings (`""`) are automatically filtered out of requests. This ensures that API servers don't receive unnecessary empty parameters that might cause issues. - -For example, if you call a tool with these parameters: -```python -await client.call_tool("search_products", { - "category": "electronics", # Will be included - "min_price": 100, # Will be included - "max_price": None, # Will be excluded - "brand": "", # Will be excluded -}) -``` - -The resulting HTTP request will only include `category=electronics&min_price=100`. - -#### Path Parameters - -For path parameters, which are typically required by REST APIs, FastMCP filters out `None` values and checks that all required path parameters are provided. If a required path parameter is missing or `None`, an error will be raised. - -```python -# This will work -await client.call_tool("get_product", {"product_id": 123}) - -# This will raise ValueError: "Missing required path parameters: {'product_id'}" -await client.call_tool("get_product", {"product_id": None}) -``` - -## Example: Custom Authentication - -If your API requires authentication, you can set headers on the client: - -```python -import httpx -from fastmcp import FastMCP - -# Create a client with authentication -api_client = httpx.AsyncClient( - base_url="https://api.example.com", - headers={"Authorization": "Bearer YOUR_TOKEN"} -) - -# Create an MCP server from your OpenAPI spec -mcp = FastMCP.from_openapi(openapi_spec=spec, client=api_client) -``` diff --git a/docs/servers/openapi.mdx b/docs/servers/openapi.mdx new file mode 100644 index 000000000..4ea549f91 --- /dev/null +++ b/docs/servers/openapi.mdx @@ -0,0 +1,250 @@ +--- +title: OpenAPI Integration +sidebarTitle: OpenAPI Integration +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 or FastAPI app. Users only need to provide an OpenAPI specification (3.0 or 3.1) and an API client, or their FastAPI app. + +```python +import httpx +from fastmcp import FastMCP + +# Create a client for your API +api_client = httpx.AsyncClient(base_url="https://api.example.com") + +# Load your OpenAPI spec +spec = {...} + +# Create an MCP server from your OpenAPI spec +mcp = FastMCP.from_openapi(openapi_spec=spec, client=api_client) + +if __name__ == "__main__": + mcp.run() +``` + +## Route Mapping + + + +By default, OpenAPI routes are mapped to MCP components based on these rules: + +| OpenAPI Route | Example |MCP Component | +| - | - | - | +| `GET` with path params | `GET /users/{id}` | Resource Template | +| `GET` without path params | `GET /stats` | Resource | +| `POST`, `PUT`, `PATCH`, `DELETE`, etc. | `POST /users` | Tool | + + +Internally, FastMCP uses a priority-ordered list of `RouteMap` objects to determine the component type for each route. Each `RouteMap` specifies: + +- **Methods**: HTTP methods to match (e.g. `["GET", "POST"]` or `"*"` for all) +- **Pattern**: Regex pattern to match the route path (e.g. `r"^/users/.*"` or `r".*"` for all) +- **Tags**: A set of OpenAPI tags that must all be present (`{}` means all tags) +- **MCP type**: What MCP component type to create (the options are `TOOL`, `RESOURCE`, `RESOURCE_TEMPLATE`, `PROMPT`, or `EXCLUDE` to exclude the route from the MCP server) + +Each OpenAPI route is matched against `RouteMap` objects in order, and the **first match wins** to determine the MCP component type. For example, here are the default route mappings, expressed as `RouteMap` objects in priority order: + +```python +from fastmcp.server.openapi import RouteMap, MCPType + +# Default route mappings +DEFAULT_ROUTE_MAPPINGS = [ + # GET with path parameters -> ResourceTemplate + RouteMap( + methods=["GET"], + pattern=r".*\{.*\}.*", + tags={}, + mcp_type=MCPType.RESOURCE_TEMPLATE + ), + # GET without path parameters -> Resource + RouteMap( + methods=["GET"], + pattern=r".*", + tags={}, + mcp_type=MCPType.RESOURCE + ), + # All other methods -> Tool + RouteMap( + methods="*", + pattern=r".*", + tags={}, + mcp_type=MCPType.TOOL + ), +] +``` + +### Custom Route Maps + +You can override the default behavior by providing custom route maps when creating your MCP server. Custom maps are processed **before** the default maps, so they take priority. Each OpenAPI route will be matched against your custom route maps in order, and the first match will determine the MCP component type (or exclusion!). + +```python {1, 6-18} +from fastmcp.server.openapi import RouteMap, MCPType + +mcp = FastMCP.from_openapi( + openapi_spec=spec, + client=api_client, + route_maps=[ + # All GET analytics endpoints should be tools + RouteMap( + methods=["GET"], + pattern=r"^/analytics/.*", + mcp_type=MCPType.TOOL, + ), + # Exclude all admin endpoints + RouteMap( + pattern=r"^/admin/.*", + mcp_type=MCPType.EXCLUDE, + ) + ] +) +``` + +### Treat All Routes as Tools + +To treat all routes as tools, use `RouteMap(mcp_type=MCPType.TOOL)` as your only route map. It will match all routes and create a tool for each. + +### Prevent Default Mappings + +To prevent the default mappings from being applied, add a catch-all exclusion routemap at the end of your custom route maps: `RouteMap(mcp_type=MCPType.EXCLUDE)`. Since it will match all routes, it will exclude any that weren't match by your previous rules and short-circuit the default mappings. + +### Tag-Based Routing + + + +To filter routes by OpenAPI tags, use `RouteMap(tags={...})`. The route must have ALL of the specified tags to be matched. If no tags are specified, all routes will be matched. + + +## Request Parameter Handling + +FastMCP carefully handles different types of parameters in OpenAPI requests: + +### Query Parameters + +By default, FastMCP will only include query parameters that have non-empty values. Parameters with `None` values or empty strings (`""`) are automatically filtered out of requests. This ensures that API servers don't receive unnecessary empty parameters that might cause issues. + +For example, if you call a tool with these parameters: +```python +await client.call_tool("search_products", { + "category": "electronics", # Will be included + "min_price": 100, # Will be included + "max_price": None, # Will be excluded + "brand": "", # Will be excluded +}) +``` + +The resulting HTTP request will only include `category=electronics&min_price=100`. + +### Path Parameters + +For path parameters, which are typically required by REST APIs, FastMCP filters out `None` values and checks that all required path parameters are provided. If a required path parameter is missing or `None`, an error will be raised. + +```python +# This will work +await client.call_tool("get_product", {"product_id": 123}) + +# This will raise ValueError: "Missing required path parameters: {'product_id'}" +await client.call_tool("get_product", {"product_id": None}) +``` + +## Authorization + +If your API requires authentication, set headers on the client before creating the MCP server. + +```python +import httpx +from fastmcp import FastMCP + +# Create a client with authentication +api_client = httpx.AsyncClient( + base_url="https://api.example.com", + headers={"Authorization": "Bearer YOUR_TOKEN"} +) + +# Create an MCP server from your OpenAPI spec +mcp = FastMCP.from_openapi(openapi_spec=spec, client=api_client) +``` + +## Timeouts + +You can set a timeout for all requests by providing a `timeout` parameter (in seconds): + +```python +mcp = FastMCP.from_openapi( + openapi_spec=spec, + client=api_client, + timeout=30.0 # 30 second timeout +) +``` + +## FastAPI Integration + + + +FastMCP can automatically convert FastAPI applications into MCP servers by extracting their OpenAPI specifications. A special client will be created that uses an in-memory ASGI transport to avoid network calls to your FastAPI app. Note that the resulting MCP server is *not* a FastAPI app itself, but can be added to one (see [ASGI integration](/deployment/asgi)). + + +FastMCP does *not* include FastAPI as a dependency; you must install it separately to use this integration. + + +```python +from fastapi import FastAPI +from fastmcp import FastMCP + +# A FastAPI app +app = FastAPI() + +@app.get("/items", tags=["items"]) +def list_items(): + return [{"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}] + +@app.get("/items/{item_id}", tags=["items", "detail"]) +def get_item(item_id: int): + return {"id": item_id, "name": f"Item {item_id}"} + +@app.post("/items", tags=["items", "create"]) +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 +``` + +### Configuration Options + +**Timeout**: You can set a timeout for all API requests: + +```python +# Set a 5 second timeout for all requests +mcp = FastMCP.from_fastapi(app=app, timeout=5.0) +``` + +**Route Mapping**: All the route mapping features (including tags) work with FastAPI apps: + +```python +from fastmcp.server.openapi import RouteMap, MCPType + +# Use tag-based routing with FastAPI +mcp = FastMCP.from_fastapi( + app=app, + route_maps=[ + RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL, tags={"admin"}), + RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.EXCLUDE, tags={"internal"}), + ] +) +``` + +### Benefits + +- **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/examples/tags_example.py b/examples/tags_example.py new file mode 100644 index 000000000..fa79a60df --- /dev/null +++ b/examples/tags_example.py @@ -0,0 +1,141 @@ +""" +Example demonstrating RouteMap tags functionality. + +This example shows how to use the tags parameter in RouteMap +to selectively route OpenAPI endpoints based on their tags. +""" + +import asyncio + +from fastapi import FastAPI + +from fastmcp import FastMCP +from fastmcp.server.openapi import MCPType, RouteMap + +# Create a FastAPI app with tagged endpoints +app = FastAPI(title="Tagged API Example") + + +@app.get("/users", tags=["users", "public"]) +async def get_users(): + """Get all users - public endpoint""" + return [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}] + + +@app.post("/users", tags=["users", "admin"]) +async def create_user(name: str): + """Create a user - admin only""" + return {"id": 3, "name": name} + + +@app.get("/admin/stats", tags=["admin", "internal"]) +async def get_admin_stats(): + """Get admin statistics - internal use""" + return {"total_users": 100, "active_sessions": 25} + + +@app.get("/health", tags=["public"]) +async def health_check(): + """Public health check""" + return {"status": "healthy"} + + +@app.get("/metrics") +async def get_metrics(): + """Metrics endpoint with no tags""" + return {"requests": 1000, "errors": 5} + + +async def main(): + """Demonstrate different tag-based routing strategies.""" + + print("=== Example 1: Make admin-tagged routes tools ===") + + # Strategy 1: Convert admin-tagged routes to tools + mcp1 = FastMCP.from_fastapi( + app=app, + route_maps=[ + RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL, tags={"admin"}), + RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE), + ], + ) + + tools = await mcp1.get_tools() + resources = await mcp1.get_resources() + + print(f"Tools ({len(tools)}): {', '.join(tools.keys())}") + print(f"Resources ({len(resources)}): {', '.join(resources.keys())}") + + print("\n=== Example 2: Exclude internal routes ===") + + # Strategy 2: Exclude internal routes entirely + mcp2 = FastMCP.from_fastapi( + app=app, + route_maps=[ + RouteMap( + methods="*", pattern=r".*", mcp_type=MCPType.EXCLUDE, tags={"internal"} + ), + RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE), + RouteMap(methods=["POST"], pattern=r".*", mcp_type=MCPType.TOOL), + ], + ) + + tools = await mcp2.get_tools() + resources = await mcp2.get_resources() + + print(f"Tools ({len(tools)}): {', '.join(tools.keys())}") + print(f"Resources ({len(resources)}): {', '.join(resources.keys())}") + + print("\n=== Example 3: Pattern + Tags combination ===") + + # Strategy 3: Routes matching both pattern AND tags + mcp3 = FastMCP.from_fastapi( + app=app, + route_maps=[ + # Admin routes under /admin path -> tools + RouteMap( + methods="*", + pattern=r".*/admin/.*", + mcp_type=MCPType.TOOL, + tags={"admin"}, + ), + # Public routes -> tools + RouteMap( + methods="*", pattern=r".*", mcp_type=MCPType.TOOL, tags={"public"} + ), + RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE), + ], + ) + + tools = await mcp3.get_tools() + resources = await mcp3.get_resources() + + print(f"Tools ({len(tools)}): {', '.join(tools.keys())}") + print(f"Resources ({len(resources)}): {', '.join(resources.keys())}") + + print("\n=== Example 4: Multiple tag AND condition ===") + + # Strategy 4: Routes must have ALL specified tags + mcp4 = FastMCP.from_fastapi( + app=app, + route_maps=[ + # Routes with BOTH "users" AND "admin" tags -> tools + RouteMap( + methods="*", + pattern=r".*", + mcp_type=MCPType.TOOL, + tags={"users", "admin"}, + ), + RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE), + ], + ) + + tools = await mcp4.get_tools() + resources = await mcp4.get_resources() + + print(f"Tools ({len(tools)}): {', '.join(tools.keys())}") + print(f"Resources ({len(resources)}): {', '.join(resources.keys())}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py index 0bed5c537..11ea93878 100644 --- a/src/fastmcp/server/openapi.py +++ b/src/fastmcp/server/openapi.py @@ -76,6 +76,7 @@ class RouteMap: pattern: Pattern[str] | str = field(default=r".*") mcp_type: MCPType | None = field(default=None) route_type: RouteType | MCPType | None = field(default=None) + tags: set[str] = field(default_factory=set) def __post_init__(self): """Validate and process the route map after initialization.""" @@ -119,57 +120,6 @@ class RouteMap: self.route_type = self.mcp_type -# Common route map pattern functions -def EXCLUDE_ALL() -> RouteMap: - """ - Create a RouteMap that excludes all routes that haven't been matched by earlier rules. - - This is useful as the last route map to exclude any routes that don't match specific patterns. - - Returns: - RouteMap: A route map that excludes all routes - """ - return RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.EXCLUDE) - - -def ALL_TOOLS() -> RouteMap: - """ - Create a RouteMap that converts all routes to tools that haven't been matched by earlier rules. - - This is useful to replace the last item in the default route mappings to make all unmatched routes tools. - - Returns: - RouteMap: A route map that converts all routes to tools - """ - return RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL) - - -def PATTERN_AS_TOOLS(pattern: str) -> RouteMap: - """ - Create a RouteMap that converts routes matching a specific pattern to tools. - - Args: - pattern: Regex pattern to match routes - - Returns: - RouteMap: A route map that converts routes matching the pattern to tools - """ - return RouteMap(methods="*", pattern=pattern, mcp_type=MCPType.TOOL) - - -def EXCLUDE_PATTERN(pattern: str) -> RouteMap: - """ - Create a RouteMap that excludes routes matching a specific pattern. - - Args: - pattern: Regex pattern to match routes to exclude - - Returns: - RouteMap: A route map that excludes routes matching the pattern - """ - return RouteMap(methods="*", pattern=pattern, mcp_type=MCPType.EXCLUDE) - - # Default route mappings as a list, where order determines priority DEFAULT_ROUTE_MAPPINGS = [ # GET requests with path parameters go to ResourceTemplate @@ -179,7 +129,7 @@ DEFAULT_ROUTE_MAPPINGS = [ # GET requests without path parameters go to Resource RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE), # All other HTTP methods go to Tool - ALL_TOOLS(), + RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL), ] @@ -208,6 +158,15 @@ def _determine_route_type( pattern_matches = re.search(route_map.pattern, route.path) if pattern_matches: + # Check if tags match (if specified) + # If route_map.tags is empty, tags are not matched + # If route_map.tags is non-empty, all tags must be present in route.tags (AND condition) + if route_map.tags: + route_tags_set = set(route.tags or []) + if not route_map.tags.issubset(route_tags_set): + # Tags don't match, continue to next mapping + continue + # We know mcp_type is not None here due to post_init validation assert route_map.mcp_type is not None logger.debug( diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 70fe8dbb0..e9ab55b5a 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -1147,13 +1147,13 @@ class FastMCP(Generic[LifespanResultT]): """ Create a FastMCP server from an OpenAPI specification. """ - from .openapi import ALL_TOOLS, FastMCPOpenAPI + from .openapi import FastMCPOpenAPI, MCPType, RouteMap # Deprecated since 2.5.0 if all_routes_as_tools: warnings.warn( "The 'all_routes_as_tools' parameter is deprecated and will be removed in a future version. " - "Use 'route_maps=[ALL_TOOLS()]' instead.", + 'Use \'route_maps=[RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)]\' instead.', DeprecationWarning, stacklevel=2, ) @@ -1162,7 +1162,7 @@ class FastMCP(Generic[LifespanResultT]): raise ValueError("Cannot specify both all_routes_as_tools and route_maps") elif all_routes_as_tools: - route_maps = [ALL_TOOLS()] + route_maps = [RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)] return FastMCPOpenAPI( openapi_spec=openapi_spec, @@ -1184,12 +1184,13 @@ class FastMCP(Generic[LifespanResultT]): Create a FastMCP server from a FastAPI application. """ - from .openapi import ALL_TOOLS, FastMCPOpenAPI + from .openapi import FastMCPOpenAPI, MCPType, RouteMap + # Deprecated since 2.5.0 if all_routes_as_tools: warnings.warn( "The 'all_routes_as_tools' parameter is deprecated and will be removed in a future version. " - "Use 'route_maps=[ALL_TOOLS()]' instead.", + 'Use \'route_maps=[RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)]\' instead.', DeprecationWarning, stacklevel=2, ) @@ -1198,7 +1199,7 @@ class FastMCP(Generic[LifespanResultT]): raise ValueError("Cannot specify both all_routes_as_tools and route_maps") elif all_routes_as_tools: - route_maps = [ALL_TOOLS()] + route_maps = [RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)] client = httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://fastapi" diff --git a/tests/server/openapi/test_openapi.py b/tests/server/openapi/test_openapi.py index 4dfb721e0..f4d16ae08 100644 --- a/tests/server/openapi/test_openapi.py +++ b/tests/server/openapi/test_openapi.py @@ -1926,302 +1926,223 @@ class TestRouteMapWildcard: tools = mcp._tool_manager.list_tools() tool_names = {tool.name for tool in tools} - # Check that all operations were mapped as tools + # Check that all 4 operations became tools expected_tools = {"getUsers", "createUser", "getPosts", "createPost"} assert tool_names == expected_tools - # No resources or templates should be created - resources = mcp._resource_manager.get_resources() - templates = mcp._resource_manager.get_templates() - assert len(resources) == 0 - assert len(templates) == 0 - async def test_priority_specific_over_wildcard( - self, basic_openapi_spec, mock_basic_client - ): - """Test that specific method maps take priority over wildcard.""" - # Create route maps with specific method first, then wildcard - route_maps = [ - # GET operations should be mapped to resources - RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE), - # All other operations should be mapped to tools - RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL), - ] - - mcp = FastMCPOpenAPI( - openapi_spec=basic_openapi_spec, - client=mock_basic_client, - route_maps=route_maps, - ) - - # Check GET operations went to resources - resources = mcp._resource_manager.get_resources() - resource_names = {r.name for r in resources.values()} - assert "getUsers" in resource_names - assert "getPosts" in resource_names - assert len(resources) == 2 - - # Check other operations went to tools - tools = mcp._tool_manager.list_tools() - tool_names = {tool.name for tool in tools} - assert "createUser" in tool_names - assert "createPost" in tool_names - assert len(tools) == 2 - - async def test_priority_wildcard_first(self, basic_openapi_spec, mock_basic_client): - """Test that when wildcard is first, it matches everything.""" - # Create route maps with wildcard first, then specific methods - route_maps = [ - # Wildcard first matches everything - RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL), - # This should never be reached - RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE), - ] - - mcp = FastMCPOpenAPI( - openapi_spec=basic_openapi_spec, - client=mock_basic_client, - route_maps=route_maps, - ) - - # All operations should be tools - tools = mcp._tool_manager.list_tools() - assert len(tools) == 4 - - # No resources should be created - resources = mcp._resource_manager.get_resources() - assert len(resources) == 0 - - async def test_wildcard_with_specific_paths( - self, basic_openapi_spec, mock_basic_client - ): - """Test wildcard methods combined with specific path patterns.""" - route_maps = [ - # All methods on /users path -> Resources - RouteMap(methods="*", pattern=r".*/users$", mcp_type=MCPType.RESOURCE), - # All methods on /posts path -> Tools - RouteMap(methods="*", pattern=r".*/posts$", mcp_type=MCPType.TOOL), - ] - - mcp = FastMCPOpenAPI( - openapi_spec=basic_openapi_spec, - client=mock_basic_client, - route_maps=route_maps, - ) - - # Check /users operations went to resources - resources = mcp._resource_manager.get_resources() - resource_names = {r.name for r in resources.values()} - assert "getUsers" in resource_names - assert "createUser" in resource_names - assert len(resources) == 2 - - # Check /posts operations went to tools - tools = mcp._tool_manager.list_tools() - tool_names = {tool.name for tool in tools} - assert "getPosts" in tool_names - assert "createPost" in tool_names - assert len(tools) == 2 - - -class TestAllRoutesAsTools: - """Tests for the all_routes_as_tools parameter in FastMCP class methods.""" +class TestRouteMapTags: + """Tests for RouteMap tags functionality.""" @pytest.fixture - def simple_api_spec(self) -> dict: - """A simple OpenAPI spec with both GET and POST methods.""" + def tagged_openapi_spec(self) -> dict: + """Create an OpenAPI spec with various tags for testing.""" return { "openapi": "3.1.0", - "info": {"title": "Test API", "version": "1.0.0"}, + "info": {"title": "Tagged API", "version": "1.0.0"}, "paths": { - "/items": { + "/users": { "get": { - "operationId": "getItems", + "operationId": "getUsers", + "tags": ["users", "public"], "responses": {"200": {"description": "Success"}}, }, "post": { - "operationId": "createItem", + "operationId": "createUser", + "tags": ["users", "admin"], "responses": {"201": {"description": "Created"}}, }, }, + "/admin/stats": { + "get": { + "operationId": "getAdminStats", + "tags": ["admin", "internal"], + "responses": {"200": {"description": "Success"}}, + } + }, + "/health": { + "get": { + "operationId": "getHealth", + "tags": ["public"], + "responses": {"200": {"description": "Success"}}, + } + }, + "/metrics": { + "get": { + "operationId": "getMetrics", + "responses": {"200": {"description": "Success"}}, + } + }, }, } @pytest.fixture async def mock_client(self) -> httpx.AsyncClient: - """Simple mock client for testing.""" + """Create a simple mock client.""" async def _responder(request): - return httpx.Response(200, json={"result": "ok"}) + return httpx.Response(200, json={"status": "ok"}) transport = httpx.MockTransport(_responder) return httpx.AsyncClient(transport=transport, base_url="http://test") - async def test_from_openapi_all_routes_as_tools(self, simple_api_spec, mock_client): - """Test FastMCP.from_openapi with all_routes_as_tools=True.""" + async def test_tags_as_tools(self, tagged_openapi_spec, mock_client): + """Test that routes with specific tags are converted to tools.""" + # Convert routes with "admin" tag to tools + route_maps = [ + RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL, tags={"admin"}), + RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE), + ] - with pytest.warns(DeprecationWarning, match="all_routes_as_tools.*deprecated"): - server = FastMCP.from_openapi( - openapi_spec=simple_api_spec, - client=mock_client, - all_routes_as_tools=True, - ) - - # Check that all routes are tools - tools = await server.get_tools() - assert len(tools) >= 2 # Should have at least the two endpoints as tools - - # Should have no resources since all routes are tools - resources = await server.get_resources() - assert len(resources) == 0 - - # Should have no resource templates since all routes are tools - templates = await server.get_resource_templates() - assert len(templates) == 0 - - async def test_from_openapi_all_routes_as_tools_conflicting_args( - self, simple_api_spec, mock_client - ): - """Test FastMCP.from_openapi raises error when both route_maps and all_routes_as_tools are provided.""" - with pytest.raises( - ValueError, match="Cannot specify both all_routes_as_tools and route_maps" - ): - with pytest.warns( - DeprecationWarning, match="all_routes_as_tools.*deprecated" - ): - FastMCP.from_openapi( - openapi_spec=simple_api_spec, - client=mock_client, - route_maps=[ - RouteMap( - methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE - ) - ], - all_routes_as_tools=True, - ) - - async def test_from_fastapi_all_routes_as_tools(self): - """Test FastMCP.from_fastapi with all_routes_as_tools=True.""" - - try: - import fastapi - except ImportError: - pytest.skip("FastAPI not available") - - app = fastapi.FastAPI() - - @app.get("/items") - def get_items(): - return {"items": []} - - @app.post("/items") - def create_item(): - return {"item": "created"} - - with pytest.warns(DeprecationWarning, match="all_routes_as_tools.*deprecated"): - server = FastMCP.from_fastapi(app=app, all_routes_as_tools=True) - - # Check that all routes are tools - tools = await server.get_tools() - assert len(tools) >= 2 # Should have at least the two endpoints as tools - - # Should have no resources since all routes are tools - resources = await server.get_resources() - assert len(resources) == 0 - - # Should have no resource templates since all routes are tools - templates = await server.get_resource_templates() - assert len(templates) == 0 - - async def test_from_fastapi_all_routes_as_tools_conflicting_args(self): - """Test FastMCP.from_fastapi raises error when both route_maps and all_routes_as_tools are provided.""" - try: - import fastapi - except ImportError: - pytest.skip("FastAPI not available") - - app = fastapi.FastAPI() - - with pytest.raises( - ValueError, match="Cannot specify both all_routes_as_tools and route_maps" - ): - with pytest.warns( - DeprecationWarning, match="all_routes_as_tools.*deprecated" - ): - FastMCP.from_fastapi( - app=app, - route_maps=[ - RouteMap( - methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE - ) - ], - all_routes_as_tools=True, - ) - - -class TestRouteTypeExclude: - @pytest.fixture - def basic_openapi_spec(self) -> dict: - return { - "openapi": "3.0.0", - "info": {"title": "Test API", "version": "1.0.0"}, - "paths": { - "/items": { - "get": { - "operationId": "get_items", - "summary": "Get all items", - "responses": {"200": {"description": "Success"}}, - } - }, - "/users": { - "get": { - "operationId": "get_users", - "summary": "Get all users", - "responses": {"200": {"description": "Success"}}, - } - }, - "/analytics": { - "get": { - "operationId": "get_analytics", - "summary": "Get analytics data", - "responses": {"200": {"description": "Success"}}, - } - }, - }, - } - - @pytest.fixture - async def mock_client(self) -> httpx.AsyncClient: - async def _responder(request): - return httpx.Response(200, json={"success": True}) - - return httpx.AsyncClient(transport=httpx.MockTransport(_responder)) - - async def test_exclude_routes(self, basic_openapi_spec, mock_client): - # Create a server with custom mappings that exclude specific routes server = FastMCPOpenAPI( - openapi_spec=basic_openapi_spec, + openapi_spec=tagged_openapi_spec, client=mock_client, - route_maps=[ - # Exclude analytics endpoints - RouteMap( - methods=["GET"], - pattern=r"^/analytics$", - mcp_type=MCPType.EXCLUDE, - ), - # Make everything else a resource - RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE), - ], + route_maps=route_maps, ) - # Check that resources were created for non-excluded routes - resources = await server.get_resources() - resource_uris = [str(r.uri) for r in resources.values()] + # Check that admin-tagged routes are tools + tools = server._tool_manager.get_tools() + tool_names = {t.name for t in tools.values()} - # The /analytics endpoint should be excluded - assert "resource://openapi/get_items" in resource_uris - assert "resource://openapi/get_users" in resource_uris - assert "resource://openapi/get_analytics" not in resource_uris + resources = server._resource_manager.get_resources() + resource_names = {r.name for r in resources.values()} - # Should only have 2 resources (analytics is excluded) - assert len(resources) == 2 + # Routes with "admin" tag should be tools + assert "createUser" in tool_names + assert "getAdminStats" in tool_names + + # Routes without "admin" tag should be resources + assert "getUsers" in resource_names + assert "getHealth" in resource_names + assert "getMetrics" in resource_names + + async def test_exclude_tags(self, tagged_openapi_spec, mock_client): + """Test that routes with specific tags are excluded.""" + # Exclude routes with "internal" tag + route_maps = [ + RouteMap( + methods="*", pattern=r".*", mcp_type=MCPType.EXCLUDE, tags={"internal"} + ), + RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE), + RouteMap(methods=["POST"], pattern=r".*", mcp_type=MCPType.TOOL), + ] + + server = FastMCPOpenAPI( + openapi_spec=tagged_openapi_spec, + client=mock_client, + route_maps=route_maps, + ) + + # Check that internal-tagged routes are excluded + resources = server._resource_manager.get_resources() + resource_names = {r.name for r in resources.values()} + + tools = server._tool_manager.get_tools() + tool_names = {t.name for t in tools.values()} + + # Internal-tagged route should be excluded + assert "getAdminStats" not in resource_names + assert "getAdminStats" not in tool_names + + # Other routes should still be present + assert "getUsers" in resource_names + assert "getHealth" in resource_names + assert "getMetrics" in resource_names + assert "createUser" in tool_names + + async def test_multiple_tags_and_condition(self, tagged_openapi_spec, mock_client): + """Test that routes must have ALL specified tags (AND condition).""" + # Routes must have BOTH "users" AND "admin" tags + route_maps = [ + RouteMap( + methods="*", + pattern=r".*", + mcp_type=MCPType.TOOL, + tags={"users", "admin"}, + ), + RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE), + ] + + server = FastMCPOpenAPI( + openapi_spec=tagged_openapi_spec, + client=mock_client, + route_maps=route_maps, + ) + + tools = server._tool_manager.get_tools() + tool_names = {t.name for t in tools.values()} + + resources = server._resource_manager.get_resources() + resource_names = {r.name for r in resources.values()} + + # Only createUser has both "users" AND "admin" tags + assert "createUser" in tool_names + + # Other routes should be resources + assert "getUsers" in resource_names # has "users" but not "admin" + assert "getAdminStats" in resource_names # has "admin" but not "users" + assert "getHealth" in resource_names + assert "getMetrics" in resource_names + + async def test_pattern_and_tags_combination(self, tagged_openapi_spec, mock_client): + """Test that both pattern and tags must be satisfied.""" + # Routes matching pattern AND having specific tags + route_maps = [ + RouteMap( + methods="*", + pattern=r".*/admin/.*", + mcp_type=MCPType.TOOL, + tags={"admin"}, + ), + RouteMap(methods=["GET"], pattern=r".*", mcp_type=MCPType.RESOURCE), + RouteMap(methods=["POST"], pattern=r".*", mcp_type=MCPType.TOOL), + ] + + server = FastMCPOpenAPI( + openapi_spec=tagged_openapi_spec, + client=mock_client, + route_maps=route_maps, + ) + + tools = server._tool_manager.get_tools() + tool_names = {t.name for t in tools.values()} + + resources = server._resource_manager.get_resources() + resource_names = {r.name for r in resources.values()} + + # Only getAdminStats matches both /admin/ pattern AND "admin" tag + assert "getAdminStats" in tool_names + + # createUser has "admin" tag but doesn't match pattern, so it becomes a tool via POST rule + assert "createUser" in tool_names + + # Other routes should be resources (GET) + assert "getUsers" in resource_names + assert "getHealth" in resource_names + assert "getMetrics" in resource_names + + async def test_empty_tags_ignored(self, tagged_openapi_spec, mock_client): + """Test that empty tags set is ignored (matches all routes).""" + # Empty tags should match all routes + route_maps = [ + RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL, tags=set()), + ] + + server = FastMCPOpenAPI( + openapi_spec=tagged_openapi_spec, + client=mock_client, + route_maps=route_maps, + ) + + tools = server._tool_manager.get_tools() + tool_names = {t.name for t in tools.values()} + + # All routes should be tools since empty tags matches everything + expected_tools = { + "getUsers", + "createUser", + "getAdminStats", + "getHealth", + "getMetrics", + } + assert tool_names == expected_tools diff --git a/tests/server/test_route_map_shortcuts.py b/tests/server/test_route_map_shortcuts.py deleted file mode 100644 index 92cc5465f..000000000 --- a/tests/server/test_route_map_shortcuts.py +++ /dev/null @@ -1,208 +0,0 @@ -"""Tests for the route map shortcut functions.""" - -import httpx -import pytest - -from fastmcp.server.openapi import ( - ALL_TOOLS, - EXCLUDE_ALL, - EXCLUDE_PATTERN, - PATTERN_AS_TOOLS, - FastMCPOpenAPI, - MCPType, - RouteMap, -) - - -class TestRouteMapShortcuts: - """Tests for the route map shortcut functions.""" - - def test_functions_return_correct_route_maps(self): - """Test that each shortcut function returns a RouteMap with the expected properties.""" - # Test EXCLUDE_ALL - exclude_all = EXCLUDE_ALL() - assert isinstance(exclude_all, RouteMap) - assert exclude_all.methods == "*" - assert exclude_all.pattern == ".*" - assert exclude_all.mcp_type == MCPType.EXCLUDE - - # Test ALL_TOOLS - all_tools = ALL_TOOLS() - assert isinstance(all_tools, RouteMap) - assert all_tools.methods == "*" - assert all_tools.pattern == ".*" - assert all_tools.mcp_type == MCPType.TOOL - - # Test PATTERN_AS_TOOLS - pattern = r"^/api/.*" - pattern_as_tools = PATTERN_AS_TOOLS(pattern) - assert isinstance(pattern_as_tools, RouteMap) - assert pattern_as_tools.methods == "*" - assert pattern_as_tools.pattern == pattern - assert pattern_as_tools.mcp_type == MCPType.TOOL - - # Test EXCLUDE_PATTERN - pattern = r"^/admin/.*" - exclude_pattern = EXCLUDE_PATTERN(pattern) - assert isinstance(exclude_pattern, RouteMap) - assert exclude_pattern.methods == "*" - assert exclude_pattern.pattern == pattern - assert exclude_pattern.mcp_type == MCPType.EXCLUDE - - def test_backward_compatibility(self): - """Test that backward compatibility with RouteType and route_type works.""" - from fastmcp.server.openapi import RouteType - - # Test creating a RouteMap with route_type - with pytest.warns(DeprecationWarning): - route_map = RouteMap( - methods=["GET"], pattern=r".*", route_type=RouteType.TOOL - ) - assert route_map.mcp_type == MCPType.TOOL - - # Test accessing fields on RouteType directly - # Note: importing RouteType already causes the deprecation warning, - # so we don't need to check for it again here - rt = RouteType.RESOURCE - assert rt.value == "RESOURCE" - assert rt.name == "RESOURCE" - - -class TestRouteMapShortcutsIntegration: - """Integration tests for the route map shortcut functions with FastMCPOpenAPI.""" - - @pytest.fixture - def basic_openapi_spec(self) -> dict: - """Create a simple OpenAPI spec for testing.""" - return { - "openapi": "3.0.0", - "info": {"title": "Test API", "version": "1.0.0"}, - "paths": { - "/items": { - "get": { - "operationId": "get_items", - "summary": "Get all items", - "responses": {"200": {"description": "Success"}}, - }, - "post": { - "operationId": "create_item", - "summary": "Create an item", - "responses": {"201": {"description": "Created"}}, - }, - }, - "/users": { - "get": { - "operationId": "get_users", - "summary": "Get all users", - "responses": {"200": {"description": "Success"}}, - }, - }, - "/admin": { - "get": { - "operationId": "get_admin", - "summary": "Admin endpoint", - "responses": {"200": {"description": "Success"}}, - }, - }, - "/items/{item_id}": { - "get": { - "operationId": "get_item", - "summary": "Get an item by ID", - "parameters": [ - { - "name": "item_id", - "in": "path", - "required": True, - "schema": {"type": "string"}, - } - ], - "responses": {"200": {"description": "Success"}}, - }, - }, - }, - } - - @pytest.fixture - async def mock_client(self) -> httpx.AsyncClient: - """Create a mock client for testing.""" - - async def _responder(request): - return httpx.Response(200, json={"success": True}) - - return httpx.AsyncClient(transport=httpx.MockTransport(_responder)) - - async def test_all_tools(self, basic_openapi_spec, mock_client): - """Test using ALL_TOOLS() to convert all routes to tools.""" - server = FastMCPOpenAPI( - openapi_spec=basic_openapi_spec, - client=mock_client, - route_maps=[ALL_TOOLS()], - ) - - # Check that all routes are tools - tools = await server.get_tools() - resources = await server.get_resources() - templates = await server.get_resource_templates() - - # All 5 routes should be tools - assert len(tools) == 5 - assert len(resources) == 0 - assert len(templates) == 0 - - # Check that all expected tools exist - tool_names = [t.name for t in tools.values()] - assert "get_items" in tool_names - assert "create_item" in tool_names - assert "get_users" in tool_names - assert "get_admin" in tool_names - assert "get_item" in tool_names - - async def test_exclude_pattern(self, basic_openapi_spec, mock_client): - """Test using EXCLUDE_PATTERN() to exclude specific routes.""" - server = FastMCPOpenAPI( - openapi_spec=basic_openapi_spec, - client=mock_client, - route_maps=[ - # Exclude admin endpoints - EXCLUDE_PATTERN(r"^/admin"), - # Make everything else a tool - ALL_TOOLS(), - ], - ) - - # Check that admin route is excluded - tools = await server.get_tools() - tool_names = [t.name for t in tools.values()] - - # All routes except admin should be tools - assert "get_items" in tool_names - assert "create_item" in tool_names - assert "get_users" in tool_names - assert "get_item" in tool_names - assert "get_admin" not in tool_names # This should be excluded - - async def test_pattern_as_tools(self, basic_openapi_spec, mock_client): - """Test using PATTERN_AS_TOOLS() to convert routes matching a pattern to tools.""" - server = FastMCPOpenAPI( - openapi_spec=basic_openapi_spec, - client=mock_client, - route_maps=[ - # Make /items routes tools regardless of method - PATTERN_AS_TOOLS(r"^/items"), - # Make everything else a resource - RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.RESOURCE), - ], - ) - - # Check that /items routes are tools - tools = await server.get_tools() - tool_names = [t.name for t in tools.values()] - assert "get_items" in tool_names - assert "create_item" in tool_names - assert "get_item" in tool_names - - # Check that other routes are resources - resources = await server.get_resources() - resource_names = [r.name for r in resources.values()] - assert "get_users" in resource_names - assert "get_admin" in resource_names From 51fde9058d46f1666fb59c99fe62543fc341e5ec Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 22 May 2025 22:03:20 -0400 Subject: [PATCH 069/114] Update docs/servers/openapi.mdx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/servers/openapi.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/servers/openapi.mdx b/docs/servers/openapi.mdx index 4ea549f91..c2d1b380e 100644 --- a/docs/servers/openapi.mdx +++ b/docs/servers/openapi.mdx @@ -44,7 +44,7 @@ Internally, FastMCP uses a priority-ordered list of `RouteMap` objects to determ - **Methods**: HTTP methods to match (e.g. `["GET", "POST"]` or `"*"` for all) - **Pattern**: Regex pattern to match the route path (e.g. `r"^/users/.*"` or `r".*"` for all) -- **Tags**: A set of OpenAPI tags that must all be present (`{}` means all tags) +- **Tags**: A set of OpenAPI tags that must all be present. An empty set (`{}`) means no tag filtering, so the route matches regardless of its tags. - **MCP type**: What MCP component type to create (the options are `TOOL`, `RESOURCE`, `RESOURCE_TEMPLATE`, `PROMPT`, or `EXCLUDE` to exclude the route from the MCP server) Each OpenAPI route is matched against `RouteMap` objects in order, and the **first match wins** to determine the MCP component type. For example, here are the default route mappings, expressed as `RouteMap` objects in priority order: From aa2747497c36080e1ad374c50022ad728b710d34 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 22 May 2025 22:10:45 -0400 Subject: [PATCH 070/114] Add route_map_fn for fine control --- docs/servers/openapi.mdx | 41 ++++ src/fastmcp/server/openapi.py | 42 +++- src/fastmcp/server/server.py | 6 +- tests/server/openapi/test_route_map_fn.py | 284 ++++++++++++++++++++++ 4 files changed, 370 insertions(+), 3 deletions(-) create mode 100644 tests/server/openapi/test_route_map_fn.py diff --git a/docs/servers/openapi.mdx b/docs/servers/openapi.mdx index 4ea549f91..6c8349708 100644 --- a/docs/servers/openapi.mdx +++ b/docs/servers/openapi.mdx @@ -118,6 +118,47 @@ To prevent the default mappings from being applied, add a catch-all exclusion ro To filter routes by OpenAPI tags, use `RouteMap(tags={...})`. The route must have ALL of the specified tags to be matched. If no tags are specified, all routes will be matched. +### Advanced Route Mapping + + + +For advanced users who need fine-grained control over route mapping, you can provide a `route_map_fn` callable. This function receives each route that was matched by a route map (and wasn't excluded) along with the assigned MCP type and name, and can return either `None` to accept the defaults or a `(mcp_type, name)` tuple to override the type and/or object name. + +```python +from fastmcp.server.openapi import MCPType + +def custom_route_mapper(route, mcp_type, name): + """Custom route mapping function for advanced control.""" + # Convert all admin routes to tools regardless of HTTP method + if "/admin/" in route.path: + return MCPType.TOOL, f"admin_{name}" + + # Rename all user-specific routes to have the prefix "user_" + if "/users/{id}" in route.path: + return mcp_type, f"user_{name}" + + # Accept defaults for all other routes + return None + +mcp = FastMCP.from_openapi( + openapi_spec=spec, + client=api_client, + route_map_fn=custom_route_mapper, +) +``` + +The `route_map_fn` receives: +- `route`: The OpenAPI route object with properties like `.method`, `.path`, `.operation_id`, etc. +- `mcp_type`: The assigned `MCPType` (based on route maps) +- `name`: The assigned component name (derived from operation ID or path) + +It should return either: +- `None` to accept the defaults +- `(mcp_type, name)` tuple to override the type and/or name + + +The `route_map_fn` is only called for routes that matched a route map and were **not** excluded. It will not be called for routes with `MCPType.EXCLUDE`. + ## Request Parameter Handling diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py index 11ea93878..9ac9b8d90 100644 --- a/src/fastmcp/server/openapi.py +++ b/src/fastmcp/server/openapi.py @@ -33,6 +33,9 @@ logger = get_logger(__name__) HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"] +# Type definition for the route mapping function +RouteMapFn = Callable[[openapi.HTTPRoute, "MCPType", str], tuple["MCPType", str] | None] + class MCPType(enum.Enum): """Type of FastMCP component to create from a route. @@ -614,7 +617,7 @@ class FastMCPOpenAPI(FastMCP): Example: ```python - from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap, RouteType + from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap, MCPType import httpx # Define custom route mappings @@ -633,12 +636,26 @@ class FastMCPOpenAPI(FastMCP): ), ] - # Create server with custom mappings + # Advanced: Custom route mapping function for fine-grained control + def custom_route_mapper(route, mcp_type, name): + # Convert all admin routes to tools regardless of HTTP method + if "/admin/" in route.path: + return MCPType.TOOL, f"admin_{name}" + + # Rename all user-specific routes to include "user_" + if "/users/{id}" in route.path: + return mcp_type, f"user_{name}" + + # Accept defaults for all other routes + return None + + # Create server with custom mappings and route mapper server = FastMCPOpenAPI( openapi_spec=spec, client=httpx.AsyncClient(), name="API Server", route_maps=custom_mappings, + route_map_fn=custom_route_mapper, ) ``` """ @@ -649,6 +666,7 @@ class FastMCPOpenAPI(FastMCP): client: httpx.AsyncClient, name: str | None = None, route_maps: list[RouteMap] | None = None, + route_map_fn: RouteMapFn | None = None, timeout: float | None = None, **settings: Any, ): @@ -660,6 +678,9 @@ class FastMCPOpenAPI(FastMCP): client: httpx AsyncClient for making HTTP requests name: Optional name for the server route_maps: Optional list of RouteMap objects defining route mappings + route_map_fn: Optional callable for advanced users to customize route mapping. + Receives (route, mcp_type, name) and returns (mcp_type, name) tuple or None. + Only called on routes that matched a route_map and were not excluded. timeout: Optional timeout (in seconds) for all requests **settings: Additional settings for FastMCP """ @@ -667,6 +688,7 @@ class FastMCPOpenAPI(FastMCP): self._client = client self._timeout = timeout + self._route_map_fn = route_map_fn # Keep track of names to detect collisions self._used_names = {"tools": set(), "resources": set(), "templates": set()} @@ -682,6 +704,22 @@ class FastMCPOpenAPI(FastMCP): # Generate a default name from the route component_name = self._generate_default_name(route, route_type) + # Call route_map_fn if provided and route is not excluded + if self._route_map_fn is not None and route_type != MCPType.EXCLUDE: + try: + result = self._route_map_fn(route, route_type, component_name) + if result is not None: + route_type, component_name = result + logger.debug( + f"Route {route.method} {route.path} mapping customized by route_map_fn: " + f"type={route_type.name}, name={component_name}" + ) + except Exception as e: + logger.warning( + f"Error in route_map_fn for {route.method} {route.path}: {e}. " + f"Using default values." + ) + if route_type == MCPType.TOOL: self._create_openapi_tool(route, component_name) elif route_type == MCPType.RESOURCE: diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index e9ab55b5a..3bb1e46ee 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -63,7 +63,7 @@ from fastmcp.utilities.mcp_config import MCPConfig if TYPE_CHECKING: from fastmcp.client import Client from fastmcp.client.transports import ClientTransport - from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap + from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap, RouteMapFn from fastmcp.server.proxy import FastMCPProxy logger = get_logger(__name__) @@ -1141,6 +1141,7 @@ class FastMCP(Generic[LifespanResultT]): openapi_spec: dict[str, Any], client: httpx.AsyncClient, route_maps: list[RouteMap] | None = None, + route_map_fn: RouteMapFn | None = None, all_routes_as_tools: bool = False, **settings: Any, ) -> FastMCPOpenAPI: @@ -1168,6 +1169,7 @@ class FastMCP(Generic[LifespanResultT]): openapi_spec=openapi_spec, client=client, route_maps=route_maps, + route_map_fn=route_map_fn, **settings, ) @@ -1177,6 +1179,7 @@ class FastMCP(Generic[LifespanResultT]): app: Any, name: str | None = None, route_maps: list[RouteMap] | None = None, + route_map_fn: RouteMapFn | None = None, all_routes_as_tools: bool = False, **settings: Any, ) -> FastMCPOpenAPI: @@ -1212,6 +1215,7 @@ class FastMCP(Generic[LifespanResultT]): client=client, name=name, route_maps=route_maps, + route_map_fn=route_map_fn, **settings, ) diff --git a/tests/server/openapi/test_route_map_fn.py b/tests/server/openapi/test_route_map_fn.py new file mode 100644 index 000000000..41b68fc9a --- /dev/null +++ b/tests/server/openapi/test_route_map_fn.py @@ -0,0 +1,284 @@ +"""Tests for the route_map_fn functionality in FastMCPOpenAPI.""" + +import httpx +import pytest + +from fastmcp.server.openapi import FastMCPOpenAPI, MCPType + + +@pytest.fixture +def sample_openapi_spec(): + """Sample OpenAPI spec for testing.""" + return { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "paths": { + "/users": { + "get": { + "summary": "List users", + "operationId": "listUsers", + "responses": {"200": {"description": "Success"}}, + } + }, + "/users/{id}": { + "get": { + "summary": "Get user by ID", + "operationId": "getUserById", + "parameters": [ + { + "name": "id", + "in": "path", + "required": True, + "schema": {"type": "string"}, + } + ], + "responses": {"200": {"description": "Success"}}, + } + }, + "/admin/settings": { + "get": { + "summary": "Get admin settings", + "operationId": "getAdminSettings", + "responses": {"200": {"description": "Success"}}, + }, + "post": { + "summary": "Update admin settings", + "operationId": "updateAdminSettings", + "requestBody": { + "content": {"application/json": {"schema": {"type": "object"}}} + }, + "responses": {"200": {"description": "Success"}}, + }, + }, + "/api/data": { + "get": { + "summary": "Get data", + "operationId": "getData", + "responses": {"200": {"description": "Success"}}, + } + }, + }, + } + + +@pytest.fixture +def http_client(): + """HTTP client for testing.""" + return httpx.AsyncClient() + + +def test_route_map_fn_none(sample_openapi_spec, http_client): + """Test that server works correctly when route_map_fn is None.""" + server = FastMCPOpenAPI( + openapi_spec=sample_openapi_spec, + client=http_client, + name="Test Server", + route_map_fn=None, # Explicitly set to None + ) + + assert server.name == "Test Server" + + +def test_route_map_fn_custom_type_conversion(sample_openapi_spec, http_client): + """Test that route_map_fn can convert route types.""" + + def admin_routes_to_tools(route, mcp_type, name): + """Convert all admin routes to tools.""" + if "/admin/" in route.path: + return MCPType.TOOL, f"admin_{name}" + return None + + server = FastMCPOpenAPI( + openapi_spec=sample_openapi_spec, + client=http_client, + name="Test Server", + route_map_fn=admin_routes_to_tools, + ) + + # Admin GET route should be converted to tool instead of resource + tools = server._tool_manager._tools + assert "admin_getAdminSettings" in tools + + # Admin POST route should be renamed + assert "admin_updateAdminSettings" in tools + + +def test_route_map_fn_custom_naming(sample_openapi_spec, http_client): + """Test that route_map_fn can customize naming.""" + + def prefix_user_routes(route, mcp_type, name): + """Add user_ prefix to user-related routes.""" + if "/users/" in route.path: + return mcp_type, f"user_{name}" + return None + + server = FastMCPOpenAPI( + openapi_spec=sample_openapi_spec, + client=http_client, + name="Test Server", + route_map_fn=prefix_user_routes, + ) + + # Check that user routes got renamed + templates = server._resource_manager._templates + template_names = list(templates.keys()) + + # The getUserById template should be renamed to user_getUserById + found_user_template = False + for uri in template_names: + if "user_getUserById" in uri: + found_user_template = True + break + assert found_user_template + + +def test_route_map_fn_returns_none(sample_openapi_spec, http_client): + """Test that route_map_fn returning None uses defaults.""" + + def always_return_none(route, mcp_type, name): + """Always return None to use defaults.""" + return None + + server = FastMCPOpenAPI( + openapi_spec=sample_openapi_spec, + client=http_client, + name="Test Server", + route_map_fn=always_return_none, + ) + + # Should have default behavior + assert server.name == "Test Server" + # Check that components were created with default names + tools = server._tool_manager._tools + resources = server._resource_manager._resources + templates = server._resource_manager._templates + + # Should have tools, resources, and templates based on default mapping + assert len(tools) > 0 + assert len(resources) > 0 + assert len(templates) > 0 + + +def test_route_map_fn_not_called_for_excluded_routes(sample_openapi_spec, http_client): + """Test that route_map_fn is not called for excluded routes.""" + + from fastmcp.server.openapi import RouteMap + + # Exclude all admin routes + route_maps = [ + RouteMap( + methods=["GET", "POST"], pattern=r".*/admin/.*", mcp_type=MCPType.EXCLUDE + ) + ] + + called_routes = [] + + def track_calls(route, mcp_type, name): + """Track which routes the function is called for.""" + called_routes.append(route.path) + return None + + FastMCPOpenAPI( + openapi_spec=sample_openapi_spec, + client=http_client, + name="Test Server", + route_maps=route_maps, + route_map_fn=track_calls, + ) + + # route_map_fn should not be called for excluded admin routes + assert "/admin/settings" not in called_routes + # But should be called for other routes + assert "/users" in called_routes + assert "/users/{id}" in called_routes + assert "/api/data" in called_routes + + +def test_route_map_fn_error_handling(sample_openapi_spec, http_client): + """Test that errors in route_map_fn are handled gracefully.""" + + def error_function(route, mcp_type, name): + """Function that raises an error.""" + if route.path == "/users": + raise ValueError("Test error") + return None + + # Should not raise an error, but log a warning + server = FastMCPOpenAPI( + openapi_spec=sample_openapi_spec, + client=http_client, + name="Test Server", + route_map_fn=error_function, + ) + + # Server should still be created successfully + assert server.name == "Test Server" + + +def test_route_map_fn_with_complex_logic(sample_openapi_spec, http_client): + """Test route_map_fn with complex conditional logic.""" + + def complex_mapper(route, mcp_type, name): + """Complex mapping logic.""" + # Convert admin routes to tools + if "/admin/" in route.path: + return MCPType.TOOL, f"admin_{name}" + + # Convert user parameter routes to templates with custom naming + if "/users/{" in route.path: + return MCPType.RESOURCE_TEMPLATE, f"user_template_{name}" + + # Convert list routes to resources with custom naming + if route.path.endswith("/users") or route.path.endswith("/data"): + return MCPType.RESOURCE, f"list_{name}" + + # Use defaults for everything else + return None + + server = FastMCPOpenAPI( + openapi_spec=sample_openapi_spec, + client=http_client, + name="Test Server", + route_map_fn=complex_mapper, + ) + + # Check that the complex logic was applied correctly + tools = server._tool_manager._tools + resources = server._resource_manager._resources + templates = server._resource_manager._templates + + # Admin routes should be tools + assert "admin_getAdminSettings" in tools + assert "admin_updateAdminSettings" in tools + + # List routes should be resources with custom names + found_list_resource = False + for uri in resources.keys(): + if "list_listUsers" in uri or "list_getData" in uri: + found_list_resource = True + break + assert found_list_resource + + # User parameter route should be template with custom name + found_user_template = False + for uri in templates.keys(): + if "user_template_getUserById" in uri: + found_user_template = True + break + assert found_user_template + + +def test_route_map_fn_signature_validation(): + """Test that route_map_fn has the correct signature.""" + from fastmcp.server.openapi import RouteMapFn + from fastmcp.utilities import openapi + + # This is more of a type checking test + def valid_route_map_fn( + route: openapi.HTTPRoute, mcp_type: MCPType, name: str + ) -> tuple[MCPType, str] | None: + return None + + # Should be assignable to RouteMapFn type + fn: RouteMapFn = valid_route_map_fn + assert callable(fn) From 9ca994b94d2e7626dd13f9ebb8c4d460ddda6760 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 22 May 2025 22:17:35 -0400 Subject: [PATCH 071/114] Bump 2.3.6 reference to 2.4.0 --- docs/clients/client.mdx | 2 +- docs/clients/transports.mdx | 2 +- docs/servers/composition.mdx | 2 +- docs/servers/proxy.mdx | 2 +- src/fastmcp/server/server.py | 12 ++++++------ 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/clients/client.mdx b/docs/clients/client.mdx index 70c47bfa9..c53ecee03 100644 --- a/docs/clients/client.mdx +++ b/docs/clients/client.mdx @@ -91,7 +91,7 @@ For more control over connection details (like headers for SSE, environment vari ### Multi-Server Clients - + FastMCP supports creating clients that connect to multiple MCP servers through a single client interface using a standard MCP configuration format (`MCPConfig`). This configuration approach makes it easy to connect to multiple specialized servers or create composable systems with a simple, declarative syntax. diff --git a/docs/clients/transports.mdx b/docs/clients/transports.mdx index b89f46884..3669c8b25 100644 --- a/docs/clients/transports.mdx +++ b/docs/clients/transports.mdx @@ -323,7 +323,7 @@ Communication happens through efficient in-memory queues, making it very fast an ### MCPConfig Transport - + - **Class:** `fastmcp.client.transports.MCPConfigTransport` - **Inferred From:** An instance of `MCPConfig` or a dictionary matching the MCPConfig schema diff --git a/docs/servers/composition.mdx b/docs/servers/composition.mdx index dc584bced..73b40affc 100644 --- a/docs/servers/composition.mdx +++ b/docs/servers/composition.mdx @@ -35,7 +35,7 @@ The choice of importing or mounting depends on your use case and requirements. 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. - + You can also create proxies from configuration dictionaries that follow the MCPConfig schema, which is useful for quickly connecting to one or more remote servers. See the [Proxy Servers documentation](/servers/proxy#configuration-based-proxies) for details on configuration-based proxying. Note that MCPConfig follows an emerging standard and its format may evolve over time. diff --git a/docs/servers/proxy.mdx b/docs/servers/proxy.mdx index ef2899cd2..d78d6d694 100644 --- a/docs/servers/proxy.mdx +++ b/docs/servers/proxy.mdx @@ -106,7 +106,7 @@ proxy = FastMCP.as_proxy( ### Configuration-Based Proxies - + You can create a proxy directly from a configuration dictionary that follows the MCPConfig schema. This is useful for quickly setting up proxies to remote servers without manually configuring each connection detail. diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 389ad9600..6ce1dff96 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -998,7 +998,7 @@ class FastMCP(Generic[LifespanResultT]): from fastmcp.server.proxy import FastMCPProxy if tool_separator is not None: - # Deprecated since 2.3.6 + # Deprecated since 2.4.0 warnings.warn( "The tool_separator parameter is deprecated and will be removed in a future version. " "Tools are now prefixed using 'prefix_toolname' format.", @@ -1007,7 +1007,7 @@ class FastMCP(Generic[LifespanResultT]): ) if resource_separator is not None: - # Deprecated since 2.3.6 + # Deprecated since 2.4.0 warnings.warn( "The resource_separator parameter is deprecated and ignored. " "Resource prefixes are now added using the protocol://prefix/path format.", @@ -1016,7 +1016,7 @@ class FastMCP(Generic[LifespanResultT]): ) if prompt_separator is not None: - # Deprecated since 2.3.6 + # Deprecated since 2.4.0 warnings.warn( "The prompt_separator parameter is deprecated and will be removed in a future version. " "Prompts are now prefixed using 'prefix_promptname' format.", @@ -1083,7 +1083,7 @@ class FastMCP(Generic[LifespanResultT]): prompt_separator: Deprecated. Separator for prompt names. """ if tool_separator is not None: - # Deprecated since 2.3.6 + # Deprecated since 2.4.0 warnings.warn( "The tool_separator parameter is deprecated and will be removed in a future version. " "Tools are now prefixed using 'prefix_toolname' format.", @@ -1092,7 +1092,7 @@ class FastMCP(Generic[LifespanResultT]): ) if resource_separator is not None: - # Deprecated since 2.3.6 + # Deprecated since 2.4.0 warnings.warn( "The resource_separator parameter is deprecated and ignored. " "Resource prefixes are now added using the protocol://prefix/path format.", @@ -1101,7 +1101,7 @@ class FastMCP(Generic[LifespanResultT]): ) if prompt_separator is not None: - # Deprecated since 2.3.6 + # Deprecated since 2.4.0 warnings.warn( "The prompt_separator parameter is deprecated and will be removed in a future version. " "Prompts are now prefixed using 'prefix_promptname' format.", From 6436adaaf36bc3931795493195ef9b947834302a Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 23 May 2025 11:03:57 -0400 Subject: [PATCH 072/114] Split into routemapfn and mcpcomponentfn --- README.md | 6 +- docs/getting-started/welcome.mdx | 12 +- docs/servers/openapi.mdx | 352 +++++++++++++++------- src/fastmcp/server/openapi.py | 82 +++-- src/fastmcp/server/server.py | 12 +- tests/server/openapi/test_route_map_fn.py | 247 ++++++++++----- 6 files changed, 495 insertions(+), 216 deletions(-) diff --git a/README.md b/README.md index 15356516e..dab7a305e 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,11 @@ > [!NOTE] > #### FastMCP 2.0 & The Official MCP SDK > -> Recognize the `FastMCP` name? You might have seen the version that was contributed to the [official MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk), which was based on **FastMCP 1.0**. +> FastMCP is the standard framework for building MCP servers and clients. FastMCP 1.0 was incorporated into the [official MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk). > -> **Welcome to FastMCP 2.0!** This is the actively developed successor, and it significantly expands on 1.0 by introducing powerful client capabilities, server proxying & composition, OpenAPI/FastAPI integration, and more advanced features. +> **This is FastMCP 2.0,** the actively maintained version that significantly expands on 1.0's basic server-building capabilities by introducing full client support, server composition, OpenAPI/FastAPI integration, remote server proxying, built-in testing tools, and more. > -> FastMCP 2.0 is the recommended path for building modern, powerful MCP applications. Ready to upgrade or get started? Follow the [installation instructions](https://gofastmcp.com/getting-started/installation), which include specific steps for upgrading from the official MCP SDK. +> FastMCP 2.0 is the complete toolkit for modern AI applications. Ready to upgrade or get started? Follow the [installation instructions](https://gofastmcp.com/getting-started/installation), which include specific steps for upgrading from the official MCP SDK. --- diff --git a/docs/getting-started/welcome.mdx b/docs/getting-started/welcome.mdx index aff5fe21d..23af0c674 100644 --- a/docs/getting-started/welcome.mdx +++ b/docs/getting-started/welcome.mdx @@ -24,17 +24,13 @@ if __name__ == "__main__": ``` -## FastMCP 2.0 and the Official MCP SDK +## FastMCP and the Official MCP SDK - -Recognize the `FastMCP` name? You might have seen the version that was contributed to the [official MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk), which was based on **FastMCP 1.0**. +FastMCP is the standard framework for building MCP servers and clients. FastMCP 1.0 was incorporated into the [official MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk). +**This is FastMCP 2.0,** the [actively maintained version](https://github.com/jlowin/fastmcp) that significantly expands on 1.0's basic server-building capabilities by introducing full client support, server composition, OpenAPI/FastAPI integration, remote server proxying, built-in testing tools, and more. -**Welcome to FastMCP 2.0!** This is the [actively developed successor](https://github.com/jlowin/fastmcp), and it significantly expands on 1.0 by introducing powerful client capabilities, server proxying & composition, OpenAPI/FastAPI integration, and more advanced features. - -FastMCP 2.0 is the recommended path for building modern, powerful MCP applications. Ready to upgrade or get started? Follow the [installation instructions](/getting-started/installation), which include specific steps for upgrading. - - +FastMCP 2.0 is the complete toolkit for modern AI applications. Ready to upgrade or get started? Follow the [installation instructions](/getting-started/installation), which include specific steps for upgrading from the official MCP SDK. ## What is MCP? diff --git a/docs/servers/openapi.mdx b/docs/servers/openapi.mdx index 331f63333..cc59c950d 100644 --- a/docs/servers/openapi.mdx +++ b/docs/servers/openapi.mdx @@ -1,78 +1,96 @@ --- title: OpenAPI Integration sidebarTitle: OpenAPI Integration -description: Generate MCP servers from OpenAPI specs +description: Generate MCP servers from OpenAPI specs and FastAPI apps icon: code-branch --- import { VersionBadge } from '/snippets/version-badge.mdx' -FastMCP can automatically generate an MCP server from an OpenAPI specification or FastAPI app. Users only need to provide an OpenAPI specification (3.0 or 3.1) and an API client, or their FastAPI app. +FastMCP can automatically generate an MCP server from an OpenAPI specification or FastAPI app. Instead of manually creating tools and resources, you provide an OpenAPI spec and FastMCP intelligently converts your API endpoints into the appropriate MCP components. -```python +## Quick Start + +To convert an OpenAPI specification to an MCP server, you can use the `FastMCP.from_openapi` class method. This method takes an OpenAPI specification and an async HTTPX client that can be used to make requests to the API, and returns an MCP server. + +Here's an example: +```python {11-15} import httpx from fastmcp import FastMCP -# Create a client for your API -api_client = httpx.AsyncClient(base_url="https://api.example.com") +# Create an HTTP client for your API +client = httpx.AsyncClient(base_url="https://api.example.com") -# Load your OpenAPI spec -spec = {...} +# Load your OpenAPI spec +openapi_spec = httpx.get("https://api.example.com/openapi.json").json() -# Create an MCP server from your OpenAPI spec -mcp = FastMCP.from_openapi(openapi_spec=spec, client=api_client) +# Create the MCP server +mcp = FastMCP.from_openapi( + openapi_spec=openapi_spec, + client=client, + name="My API Server" +) if __name__ == "__main__": mcp.run() ``` +That's it! Your entire API is now available as an MCP server. Clients can discover and interact with your API endpoints through the MCP protocol, with full schema validation and type safety. + + ## Route Mapping + + +FastMCP analyzes your API specification and automatically creates MCP components based on HTTP semantics and REST conventions. By default, the following rules are used to determine what MCP component to create for each route: + +| OpenAPI Route | Example | MCP Component | +|---------------|---------|---------------| +| `GET` with path params | `GET /users/{id}` | **Resource Template** | +| `GET` without path params | `GET /stats` | **Resource** | +| `POST`, `PUT`, `PATCH`, `DELETE`, etc. | `POST /users` | **Tool** | + + + +### Custom Route Maps + -By default, OpenAPI routes are mapped to MCP components based on these rules: +FastMCP uses an ordered list of `RouteMap` objects to determine how to map OpenAPI routes to various MCP component types. -| OpenAPI Route | Example |MCP Component | -| - | - | - | -| `GET` with path params | `GET /users/{id}` | Resource Template | -| `GET` without path params | `GET /stats` | Resource | -| `POST`, `PUT`, `PATCH`, `DELETE`, etc. | `POST /users` | Tool | - - -Internally, FastMCP uses a priority-ordered list of `RouteMap` objects to determine the component type for each route. Each `RouteMap` specifies: +Each `RouteMap` specifies a combination of methods, patterns, and tags, as well as a corresponding MCP component type. Each OpenAPI route is checked against each `RouteMap` in order, and the first one that matches every criteria is used to determine its converted MCP type. A special type, `EXCLUDE`, can be used to exclude routes from the MCP server entirely. - **Methods**: HTTP methods to match (e.g. `["GET", "POST"]` or `"*"` for all) - **Pattern**: Regex pattern to match the route path (e.g. `r"^/users/.*"` or `r".*"` for all) - **Tags**: A set of OpenAPI tags that must all be present. An empty set (`{}`) means no tag filtering, so the route matches regardless of its tags. -- **MCP type**: What MCP component type to create (the options are `TOOL`, `RESOURCE`, `RESOURCE_TEMPLATE`, `PROMPT`, or `EXCLUDE` to exclude the route from the MCP server) +- **MCP type**: What MCP component type to create (`TOOL`, `RESOURCE`, `RESOURCE_TEMPLATE`, or `EXCLUDE`) -Each OpenAPI route is matched against `RouteMap` objects in order, and the **first match wins** to determine the MCP component type. For example, here are the default route mappings, expressed as `RouteMap` objects in priority order: +To illustrate this in practice, here are FastMCP's default route mappings as a list of `RouteMap` objects: ```python from fastmcp.server.openapi import RouteMap, MCPType -# Default route mappings DEFAULT_ROUTE_MAPPINGS = [ - # GET with path parameters -> ResourceTemplate + + # GET with path parameters → ResourceTemplate RouteMap( methods=["GET"], pattern=r".*\{.*\}.*", - tags={}, mcp_type=MCPType.RESOURCE_TEMPLATE ), - # GET without path parameters -> Resource + + # GET without path parameters → Resource RouteMap( methods=["GET"], pattern=r".*", - tags={}, mcp_type=MCPType.RESOURCE ), - # All other methods -> Tool + + # All other methods → Tool RouteMap( - methods="*", + methods=["*"], pattern=r".*", - tags={}, mcp_type=MCPType.TOOL ), ] @@ -80,153 +98,272 @@ DEFAULT_ROUTE_MAPPINGS = [ ### Custom Route Maps -You can override the default behavior by providing custom route maps when creating your MCP server. Custom maps are processed **before** the default maps, so they take priority. Each OpenAPI route will be matched against your custom route maps in order, and the first match will determine the MCP component type (or exclusion!). +When creating your FastMCP server, you can customize routing behavior by providing your own list of `RouteMap` objects. Your custom maps are processed before the default route maps, and routes will be assigned to the first matching custom map. -```python {1, 6-18} +For example, the following simple rule will treat every OpenAPI route as a tool: + +```python {7} +from fastmcp import FastMCP from fastmcp.server.openapi import RouteMap, MCPType mcp = FastMCP.from_openapi( - openapi_spec=spec, - client=api_client, + ..., route_maps=[ - # All GET analytics endpoints should be tools + RouteMap(mcp_type=MCPType.TOOL), + ], +) +``` + +Here is a more complete example that uses custom route maps to convert all `GET` endpoints under `/analytics/` to tools while excluding all admin endpoints and all routes tagged "internal". All other routes will be handled by the default rules: + +```python +from fastmcp import FastMCP +from fastmcp.server.openapi import RouteMap, MCPType + +mcp = FastMCP.from_openapi( + ..., + route_maps=[ + + # Analytics `GET` endpoints are tools RouteMap( methods=["GET"], pattern=r"^/analytics/.*", mcp_type=MCPType.TOOL, ), + # Exclude all admin endpoints RouteMap( pattern=r"^/admin/.*", mcp_type=MCPType.EXCLUDE, - ) - ] + ), + + # Exclude all routes tagged "internal" + RouteMap( + tags={"internal"}, + mcp_type=MCPType.EXCLUDE, + ), + ], ) ``` -### Treat All Routes as Tools + +The default route maps are always applied after your custom maps, so you do not have to create route maps for every possible route. + -To treat all routes as tools, use `RouteMap(mcp_type=MCPType.TOOL)` as your only route map. It will match all routes and create a tool for each. +### Excluding Routes -### Prevent Default Mappings +To exclude routes from the MCP server, use a route map to assign them to `MCPType.EXCLUDE`. -To prevent the default mappings from being applied, add a catch-all exclusion routemap at the end of your custom route maps: `RouteMap(mcp_type=MCPType.EXCLUDE)`. Since it will match all routes, it will exclude any that weren't match by your previous rules and short-circuit the default mappings. +You can use this to remove sensitive or internal routes by targeting them specifically: -### Tag-Based Routing +```python {7,8} +from fastmcp import FastMCP +from fastmcp.server.openapi import RouteMap, MCPType - +mcp = FastMCP.from_openapi( + ..., + route_maps=[ + RouteMap(pattern=r"^/admin/.*", mcp_type=MCPType.EXCLUDE), + RouteMap(tags={"internal"}, mcp_type=MCPType.EXCLUDE), + ], +) +``` + +Or you can use a catch-all rule to exclude everything that your maps don't handle explicitly: +```python {10} +from fastmcp import FastMCP +from fastmcp.server.openapi import RouteMap, MCPType + +mcp = FastMCP.from_openapi( + ..., + route_maps=[ + # custom mapping logic goes here + ..., + # exclude all remaining routes + RouteMap(mcp_type=MCPType.EXCLUDE), + ], +) +``` + + +Using a catch-all exclusion rule will prevent the default route mappings from being applied, since it will match every remaining route. This is useful if you want to explicitly allow-list certain routes. + -To filter routes by OpenAPI tags, use `RouteMap(tags={...})`. The route must have ALL of the specified tags to be matched. If no tags are specified, all routes will be matched. ### Advanced Route Mapping -For advanced users who need fine-grained control over route mapping, you can provide a `route_map_fn` callable. This function receives each route that was matched by a route map (and wasn't excluded) along with the assigned MCP type and name, and can return either `None` to accept the defaults or a `(mcp_type, name)` tuple to override the type and/or object name. +For advanced use cases that require more complex logic, you can provide a `route_map_fn` callable. After the route map logic is applied, this function is called on each matched route and its assigned MCP component type. It can optionally return a different component type to override the mapped assignment. If it returns `None`, the assigned type is used. + +In addition to more precise targeting of methods, patterns, and tags, this function can access any additional OpenAPI metadata about the route. + + +The `route_map_fn` **is** called on routes that matched `MCPType.EXCLUDE` in your custom maps, giving you an opportunity to override the exclusion. + + ```python -from fastmcp.server.openapi import MCPType +from fastmcp import FastMCP +from fastmcp.server.openapi import RouteMap, MCPType, HTTPRoute -def custom_route_mapper(route, mcp_type, name): - """Custom route mapping function for advanced control.""" +def custom_route_mapper(route: HTTPRoute, mcp_type: MCPType) -> MCPType | None: + """Advanced route type mapping.""" # Convert all admin routes to tools regardless of HTTP method if "/admin/" in route.path: - return MCPType.TOOL, f"admin_{name}" + return MCPType.TOOL + + elif "internal" in route.tags: + return MCPType.EXCLUDE - # Rename all user-specific routes to have the prefix "user_" - if "/users/{id}" in route.path: - return mcp_type, f"user_{name}" + # Convert user detail routes to templates even if they're POST + elif route.path.startswith("/users/") and route.method == "POST": + return MCPType.RESOURCE_TEMPLATE - # Accept defaults for all other routes + # Use defaults for all other routes return None mcp = FastMCP.from_openapi( - openapi_spec=spec, - client=api_client, + ..., route_map_fn=custom_route_mapper, ) ``` -The `route_map_fn` receives: -- `route`: The OpenAPI route object with properties like `.method`, `.path`, `.operation_id`, etc. -- `mcp_type`: The assigned `MCPType` (based on route maps) -- `name`: The assigned component name (derived from operation ID or path) +## Customizing MCP Components -It should return either: -- `None` to accept the defaults -- `(mcp_type, name)` tuple to override the type and/or name + - -The `route_map_fn` is only called for routes that matched a route map and were **not** excluded. It will not be called for routes with `MCPType.EXCLUDE`. - +By default, FastMCP creates MCP components using a variety of metadata from the OpenAPI spec, such as incorporating the OpenAPI description into the MCP component description. + +At times you may want to modify those MCP components in a variety of ways, such as adding LLM-specific instructions or tags. For fine-grained customization, you can provide a `mcp_component_fn` when creating the MCP server. After each MCP component has been created, this function is called on it and has the opportunity to modify it in-place. + + +Your `mcp_component_fn` is expected to modify the component in-place, not to return a new component. The result of the function is ignored. + + +```python {27} +from fastmcp import FastMCP +from fastmcp.server.openapi import ( + HTTPRoute, + OpenAPITool, + OpenAPIResource, + OpenAPIResourceTemplate, +) + +def customize_components( + route: HTTPRoute, + component: OpenAPITool | OpenAPIResource | OpenAPIResourceTemplate, +) -> None: + + # Add custom tags to all components + component.tags.add("openapi") + + # Customize based on component type + if isinstance(component, OpenAPITool): + component.description = f"🔧 {component.description} (via API)" + + if isinstance(component, OpenAPIResource): + component.description = f"📊 {component.description}" + component.tags.add("data") + +mcp = FastMCP.from_openapi( + ..., + mcp_component_fn=customize_components, +) +``` ## Request Parameter Handling -FastMCP carefully handles different types of parameters in OpenAPI requests: +FastMCP intelligently handles different types of parameters in OpenAPI requests: ### Query Parameters -By default, FastMCP will only include query parameters that have non-empty values. Parameters with `None` values or empty strings (`""`) are automatically filtered out of requests. This ensures that API servers don't receive unnecessary empty parameters that might cause issues. +By default, FastMCP only includes query parameters that have non-empty values. Parameters with `None` values or empty strings are automatically filtered out. -For example, if you call a tool with these parameters: ```python +# When calling this tool... await client.call_tool("search_products", { - "category": "electronics", # Will be included - "min_price": 100, # Will be included - "max_price": None, # Will be excluded - "brand": "", # Will be excluded + "category": "electronics", # ✅ Included + "min_price": 100, # ✅ Included + "max_price": None, # ❌ Excluded + "brand": "", # ❌ Excluded }) -``` -The resulting HTTP request will only include `category=electronics&min_price=100`. +# The HTTP request will be: GET /products?category=electronics&min_price=100 +``` ### Path Parameters -For path parameters, which are typically required by REST APIs, FastMCP filters out `None` values and checks that all required path parameters are provided. If a required path parameter is missing or `None`, an error will be raised. +Path parameters are typically required by REST APIs. FastMCP: +- Filters out `None` values +- Validates that all required path parameters are provided +- Raises clear errors for missing required parameters ```python -# This will work -await client.call_tool("get_product", {"product_id": 123}) +# ✅ This works +await client.call_tool("get_user", {"user_id": 123}) -# This will raise ValueError: "Missing required path parameters: {'product_id'}" -await client.call_tool("get_product", {"product_id": None}) +# ❌ This raises: "Missing required path parameters: {'user_id'}" +await client.call_tool("get_user", {"user_id": None}) ``` -## Authorization +### Array Parameters -If your API requires authentication, set headers on the client before creating the MCP server. +FastMCP handles array parameters according to OpenAPI specifications: + +- **Query arrays**: Serialized based on the `explode` parameter (default: `True`) +- **Path arrays**: Serialized as comma-separated values (OpenAPI 'simple' style) + +```python +# Query array with explode=true (default) +# ?tags=red&tags=blue&tags=green + +# Query array with explode=false +# ?tags=red,blue,green + +# Path array (always comma-separated) +# /items/red,blue,green +``` + +### Headers + +Header parameters are automatically converted to strings and included in the HTTP request. + +## Auth + +If your API requires authentication, configure it on the HTTP client before creating the MCP server: ```python import httpx from fastmcp import FastMCP -# Create a client with authentication +# Bearer token authentication api_client = httpx.AsyncClient( base_url="https://api.example.com", headers={"Authorization": "Bearer YOUR_TOKEN"} ) -# Create an MCP server from your OpenAPI spec -mcp = FastMCP.from_openapi(openapi_spec=spec, client=api_client) +# Create MCP server with authenticated client +mcp = FastMCP.from_openapi(..., client=api_client) ``` - ## Timeouts -You can set a timeout for all requests by providing a `timeout` parameter (in seconds): +Set a timeout for all API requests: ```python mcp = FastMCP.from_openapi( openapi_spec=spec, client=api_client, - timeout=30.0 # 30 second timeout + timeout=30.0 # 30 second timeout for all requests ) ``` + ## FastAPI Integration -FastMCP can automatically convert FastAPI applications into MCP servers by extracting their OpenAPI specifications. A special client will be created that uses an in-memory ASGI transport to avoid network calls to your FastAPI app. Note that the resulting MCP server is *not* a FastAPI app itself, but can be added to one (see [ASGI integration](/deployment/asgi)). +FastMCP can directly convert FastAPI applications into MCP servers by extracting their OpenAPI specifications: FastMCP does *not* include FastAPI as a dependency; you must install it separately to use this integration. @@ -236,8 +373,8 @@ FastMCP does *not* include FastAPI as a dependency; you must install it separate from fastapi import FastAPI from fastmcp import FastMCP -# A FastAPI app -app = FastAPI() +# Your FastAPI app +app = FastAPI(title="My API", version="1.0.0") @app.get("/items", tags=["items"]) def list_items(): @@ -251,41 +388,46 @@ def get_item(item_id: int): def create_item(name: str): return {"id": 3, "name": name} -# Create an MCP server from your FastAPI app +# Convert FastAPI app to MCP server mcp = FastMCP.from_fastapi(app=app) if __name__ == "__main__": - mcp.run() # Start the MCP server + mcp.run() # Run as MCP server ``` -### Configuration Options + +FastMCP servers are not FastAPI apps, even when created from one. To learn how to deploy them as an ASGI app, see the [ASGI Integration](/deployment/asgi) documentation. + -**Timeout**: You can set a timeout for all API requests: -```python -# Set a 5 second timeout for all requests -mcp = FastMCP.from_fastapi(app=app, timeout=5.0) -``` -**Route Mapping**: All the route mapping features (including tags) work with FastAPI apps: +### FastAPI Configuration + +All OpenAPI integration features work with FastAPI apps: ```python from fastmcp.server.openapi import RouteMap, MCPType -# Use tag-based routing with FastAPI +# Custom route mapping with FastAPI mcp = FastMCP.from_fastapi( app=app, + name="My Custom Server", + timeout=5.0, route_maps=[ - RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL, tags={"admin"}), + # Admin endpoints become tools + RouteMap(methods="*", pattern=r"^/admin/.*", mcp_type=MCPType.TOOL), + # Internal endpoints are excluded RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.EXCLUDE, tags={"internal"}), - ] + ], + route_map_fn=my_route_mapper, + mcp_component_fn=my_component_customizer, ) ``` -### Benefits +### FastAPI Benefits -- **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 +- **Zero code duplication**: Reuse existing FastAPI endpoints +- **Schema inheritance**: Pydantic models and validation are preserved +- **ASGI transport**: Direct in-memory communication (no HTTP overhead) +- **Full FastAPI features**: Dependencies, middleware, authentication all work diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py index 9ac9b8d90..044325ae9 100644 --- a/src/fastmcp/server/openapi.py +++ b/src/fastmcp/server/openapi.py @@ -22,6 +22,7 @@ from fastmcp.tools.tool import Tool, _convert_to_content from fastmcp.utilities import openapi from fastmcp.utilities.logging import get_logger from fastmcp.utilities.openapi import ( + HTTPRoute, _combine_schemas, format_description_with_responses, ) @@ -33,8 +34,15 @@ logger = get_logger(__name__) HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"] -# Type definition for the route mapping function -RouteMapFn = Callable[[openapi.HTTPRoute, "MCPType", str], tuple["MCPType", str] | None] +# Type definitions for the mapping functions +RouteMapFn = Callable[[HTTPRoute, "MCPType"], "MCPType | None"] +ComponentFn = Callable[ + [ + HTTPRoute, + "OpenAPITool | OpenAPIResource | OpenAPIResourceTemplate", + ], + None, +] class MCPType(enum.Enum): @@ -44,7 +52,6 @@ class MCPType(enum.Enum): TOOL: Convert the route to a callable Tool RESOURCE: Convert the route to a Resource (typically GET endpoints) RESOURCE_TEMPLATE: Convert the route to a ResourceTemplate (typically GET with path params) - PROMPT: Convert the route to a Prompt (not yet implemented) EXCLUDE: Exclude the route from being converted to any MCP component IGNORE: Deprecated, use EXCLUDE instead """ @@ -52,7 +59,7 @@ class MCPType(enum.Enum): TOOL = "TOOL" RESOURCE = "RESOURCE" RESOURCE_TEMPLATE = "RESOURCE_TEMPLATE" - PROMPT = "PROMPT" + # PROMPT = "PROMPT" EXCLUDE = "EXCLUDE" @@ -67,7 +74,6 @@ class RouteType(enum.Enum): TOOL = "TOOL" RESOURCE = "RESOURCE" RESOURCE_TEMPLATE = "RESOURCE_TEMPLATE" - PROMPT = "PROMPT" IGNORE = "IGNORE" @@ -667,6 +673,7 @@ class FastMCPOpenAPI(FastMCP): name: str | None = None, route_maps: list[RouteMap] | None = None, route_map_fn: RouteMapFn | None = None, + mcp_component_fn: ComponentFn | None = None, timeout: float | None = None, **settings: Any, ): @@ -678,9 +685,12 @@ class FastMCPOpenAPI(FastMCP): client: httpx AsyncClient for making HTTP requests name: Optional name for the server route_maps: Optional list of RouteMap objects defining route mappings - route_map_fn: Optional callable for advanced users to customize route mapping. - Receives (route, mcp_type, name) and returns (mcp_type, name) tuple or None. + route_map_fn: Optional callable for advanced route type mapping. + Receives (route, mcp_type) and returns MCPType or None. Only called on routes that matched a route_map and were not excluded. + component_fn: Optional callable for component customization. + Receives (route, component) and can modify the component in-place. + Called on every created component. timeout: Optional timeout (in seconds) for all requests **settings: Additional settings for FastMCP """ @@ -689,6 +699,7 @@ class FastMCPOpenAPI(FastMCP): self._client = client self._timeout = timeout self._route_map_fn = route_map_fn + self._mcp_component_fn = mcp_component_fn # Keep track of names to detect collisions self._used_names = {"tools": set(), "resources": set(), "templates": set()} @@ -701,18 +712,15 @@ class FastMCPOpenAPI(FastMCP): # Determine route type based on mappings or default rules route_type = _determine_route_type(route, route_maps) - # Generate a default name from the route - component_name = self._generate_default_name(route, route_type) - - # Call route_map_fn if provided and route is not excluded - if self._route_map_fn is not None and route_type != MCPType.EXCLUDE: + # Call route_map_fn if provided + if self._route_map_fn is not None: try: - result = self._route_map_fn(route, route_type, component_name) + result = self._route_map_fn(route, route_type) if result is not None: - route_type, component_name = result + route_type = result logger.debug( f"Route {route.method} {route.path} mapping customized by route_map_fn: " - f"type={route_type.name}, name={component_name}" + f"type={route_type.name}" ) except Exception as e: logger.warning( @@ -720,17 +728,15 @@ class FastMCPOpenAPI(FastMCP): f"Using default values." ) + # Generate a default name from the route + component_name = self._generate_default_name(route, route_type) + if route_type == MCPType.TOOL: self._create_openapi_tool(route, component_name) elif route_type == MCPType.RESOURCE: self._create_openapi_resource(route, component_name) elif route_type == MCPType.RESOURCE_TEMPLATE: self._create_openapi_template(route, component_name) - elif route_type == MCPType.PROMPT: - # Not implemented yet - logger.warning( - f"PROMPT route type not implemented: {route.method} {route.path}" - ) elif route_type == MCPType.EXCLUDE: logger.info(f"Excluding route: {route.method} {route.path}") @@ -833,6 +839,18 @@ class FastMCPOpenAPI(FastMCP): tags=set(route.tags or []), timeout=self._timeout, ) + + # Call component_fn if provided + if self._mcp_component_fn is not None: + try: + self._mcp_component_fn(route, tool) + logger.debug(f"Tool {tool_name} customized by component_fn") + except Exception as e: + logger.warning( + f"Error in component_fn for tool {tool_name}: {e}. " + f"Using component as-is." + ) + # Register the tool by directly assigning to the tools dictionary self._tool_manager._tools[tool_name] = tool logger.debug( @@ -866,6 +884,18 @@ class FastMCPOpenAPI(FastMCP): tags=set(route.tags or []), timeout=self._timeout, ) + + # Call component_fn if provided + if self._mcp_component_fn is not None: + try: + self._mcp_component_fn(route, resource) + logger.debug(f"Resource {resource_uri} customized by component_fn") + except Exception as e: + logger.warning( + f"Error in component_fn for resource {resource_uri}: {e}. " + f"Using component as-is." + ) + # Register the resource by directly assigning to the resources dictionary self._resource_manager._resources[str(resource.uri)] = resource logger.debug( @@ -928,6 +958,18 @@ class FastMCPOpenAPI(FastMCP): tags=set(route.tags or []), timeout=self._timeout, ) + + # Call component_fn if provided + if self._mcp_component_fn is not None: + try: + self._mcp_component_fn(route, template) + logger.debug(f"Template {uri_template_str} customized by component_fn") + except Exception as e: + logger.warning( + f"Error in component_fn for template {uri_template_str}: {e}. " + f"Using component as-is." + ) + # Register the template by directly assigning to the templates dictionary self._resource_manager._templates[uri_template_str] = template logger.debug( diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index c8a37aff3..99d779dcb 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -63,7 +63,9 @@ from fastmcp.utilities.mcp_config import MCPConfig if TYPE_CHECKING: from fastmcp.client import Client from fastmcp.client.transports import ClientTransport - from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap, RouteMapFn + from fastmcp.server.openapi import ComponentFn as OpenAPIComponentFn + from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap + from fastmcp.server.openapi import RouteMapFn as OpenAPIRouteMapFn from fastmcp.server.proxy import FastMCPProxy logger = get_logger(__name__) @@ -1141,7 +1143,8 @@ class FastMCP(Generic[LifespanResultT]): openapi_spec: dict[str, Any], client: httpx.AsyncClient, route_maps: list[RouteMap] | None = None, - route_map_fn: RouteMapFn | None = None, + route_map_fn: OpenAPIRouteMapFn | None = None, + mcp_component_fn: OpenAPIComponentFn | None = None, all_routes_as_tools: bool = False, **settings: Any, ) -> FastMCPOpenAPI: @@ -1170,6 +1173,7 @@ class FastMCP(Generic[LifespanResultT]): client=client, route_maps=route_maps, route_map_fn=route_map_fn, + mcp_component_fn=mcp_component_fn, **settings, ) @@ -1179,7 +1183,8 @@ class FastMCP(Generic[LifespanResultT]): app: Any, name: str | None = None, route_maps: list[RouteMap] | None = None, - route_map_fn: RouteMapFn | None = None, + route_map_fn: OpenAPIRouteMapFn | None = None, + mcp_component_fn: OpenAPIComponentFn | None = None, all_routes_as_tools: bool = False, **settings: Any, ) -> FastMCPOpenAPI: @@ -1216,6 +1221,7 @@ class FastMCP(Generic[LifespanResultT]): name=name, route_maps=route_maps, route_map_fn=route_map_fn, + mcp_component_fn=mcp_component_fn, **settings, ) diff --git a/tests/server/openapi/test_route_map_fn.py b/tests/server/openapi/test_route_map_fn.py index 41b68fc9a..44acde060 100644 --- a/tests/server/openapi/test_route_map_fn.py +++ b/tests/server/openapi/test_route_map_fn.py @@ -1,4 +1,4 @@ -"""Tests for the route_map_fn functionality in FastMCPOpenAPI.""" +"""Tests for the route_map_fn and component_fn functionality in FastMCPOpenAPI.""" import httpx import pytest @@ -82,10 +82,10 @@ def test_route_map_fn_none(sample_openapi_spec, http_client): def test_route_map_fn_custom_type_conversion(sample_openapi_spec, http_client): """Test that route_map_fn can convert route types.""" - def admin_routes_to_tools(route, mcp_type, name): + def admin_routes_to_tools(route, mcp_type): """Convert all admin routes to tools.""" if "/admin/" in route.path: - return MCPType.TOOL, f"admin_{name}" + return MCPType.TOOL return None server = FastMCPOpenAPI( @@ -97,45 +97,58 @@ def test_route_map_fn_custom_type_conversion(sample_openapi_spec, http_client): # Admin GET route should be converted to tool instead of resource tools = server._tool_manager._tools - assert "admin_getAdminSettings" in tools + assert "getAdminSettings" in tools - # Admin POST route should be renamed - assert "admin_updateAdminSettings" in tools + # Admin POST route should still be a tool (was already) + assert "updateAdminSettings" in tools -def test_route_map_fn_custom_naming(sample_openapi_spec, http_client): - """Test that route_map_fn can customize naming.""" +def test_component_fn_customization(sample_openapi_spec, http_client): + """Test that component_fn can customize components.""" - def prefix_user_routes(route, mcp_type, name): - """Add user_ prefix to user-related routes.""" - if "/users/" in route.path: - return mcp_type, f"user_{name}" - return None + def customize_components(route, component): + """Customize components based on route.""" + from fastmcp.server.openapi import OpenAPIResource, OpenAPITool + + # Add custom tags to all components + component.tags.add("custom") + + # Modify tool descriptions + if isinstance(component, OpenAPITool): + component.description = (component.description or "") + " [CUSTOMIZED TOOL]" + + # Modify resource descriptions + if isinstance(component, OpenAPIResource): + component.description = ( + component.description or "" + ) + " [CUSTOMIZED RESOURCE]" server = FastMCPOpenAPI( openapi_spec=sample_openapi_spec, client=http_client, name="Test Server", - route_map_fn=prefix_user_routes, + mcp_component_fn=customize_components, ) - # Check that user routes got renamed - templates = server._resource_manager._templates - template_names = list(templates.keys()) + # Check that components were customized + tools = server._tool_manager._tools + resources = server._resource_manager._resources - # The getUserById template should be renamed to user_getUserById - found_user_template = False - for uri in template_names: - if "user_getUserById" in uri: - found_user_template = True - break - assert found_user_template + # Tools should have custom tags and modified descriptions + for tool in tools.values(): + assert "custom" in tool.tags + assert "[CUSTOMIZED TOOL]" in (tool.description or "") + + # Resources should have custom tags and modified descriptions + for resource in resources.values(): + assert "custom" in resource.tags + assert "[CUSTOMIZED RESOURCE]" in (resource.description or "") def test_route_map_fn_returns_none(sample_openapi_spec, http_client): """Test that route_map_fn returning None uses defaults.""" - def always_return_none(route, mcp_type, name): + def always_return_none(route, mcp_type): """Always return None to use defaults.""" return None @@ -148,7 +161,7 @@ def test_route_map_fn_returns_none(sample_openapi_spec, http_client): # Should have default behavior assert server.name == "Test Server" - # Check that components were created with default names + # Check that components were created with default types tools = server._tool_manager._tools resources = server._resource_manager._resources templates = server._resource_manager._templates @@ -159,8 +172,8 @@ def test_route_map_fn_returns_none(sample_openapi_spec, http_client): assert len(templates) > 0 -def test_route_map_fn_not_called_for_excluded_routes(sample_openapi_spec, http_client): - """Test that route_map_fn is not called for excluded routes.""" +def test_route_map_fn_called_for_excluded_routes(sample_openapi_spec, http_client): + """Test that route_map_fn is called for excluded routes and can rescue them.""" from fastmcp.server.openapi import RouteMap @@ -173,31 +186,42 @@ def test_route_map_fn_not_called_for_excluded_routes(sample_openapi_spec, http_c called_routes = [] - def track_calls(route, mcp_type, name): - """Track which routes the function is called for.""" + def track_calls_and_rescue(route, mcp_type): + """Track which routes the function is called for and rescue some excluded routes.""" called_routes.append(route.path) - return None - FastMCPOpenAPI( + # Rescue the admin GET route by converting it to a tool + if route.path == "/admin/settings" and route.method == "GET": + return MCPType.TOOL + + return None # Accept the assignment for other routes + + server = FastMCPOpenAPI( openapi_spec=sample_openapi_spec, client=http_client, name="Test Server", route_maps=route_maps, - route_map_fn=track_calls, + route_map_fn=track_calls_and_rescue, ) - # route_map_fn should not be called for excluded admin routes - assert "/admin/settings" not in called_routes - # But should be called for other routes + # route_map_fn should now be called for all routes, including excluded admin routes + assert "/admin/settings" in called_routes assert "/users" in called_routes assert "/users/{id}" in called_routes assert "/api/data" in called_routes + # The rescued admin GET route should now be a tool + tools = server._tool_manager._tools + assert "getAdminSettings" in tools + + # The admin POST route should still be excluded (not rescued) + assert "updateAdminSettings" not in tools + def test_route_map_fn_error_handling(sample_openapi_spec, http_client): """Test that errors in route_map_fn are handled gracefully.""" - def error_function(route, mcp_type, name): + def error_function(route, mcp_type): """Function that raises an error.""" if route.path == "/users": raise ValueError("Test error") @@ -215,57 +239,59 @@ def test_route_map_fn_error_handling(sample_openapi_spec, http_client): assert server.name == "Test Server" -def test_route_map_fn_with_complex_logic(sample_openapi_spec, http_client): - """Test route_map_fn with complex conditional logic.""" +def test_component_fn_error_handling(sample_openapi_spec, http_client): + """Test that errors in component_fn are handled gracefully.""" - def complex_mapper(route, mcp_type, name): - """Complex mapping logic.""" - # Convert admin routes to tools + def error_function(route, component): + """Function that raises an error.""" + if route.path == "/users": + raise ValueError("Test error in component_fn") + + # Should not raise an error, but log a warning + server = FastMCPOpenAPI( + openapi_spec=sample_openapi_spec, + client=http_client, + name="Test Server", + mcp_component_fn=error_function, + ) + + # Server should still be created successfully + assert server.name == "Test Server" + + +def test_combined_route_map_fn_and_component_fn(sample_openapi_spec, http_client): + """Test using both route_map_fn and component_fn together.""" + + def route_mapper(route, mcp_type): + """Convert admin routes to tools.""" if "/admin/" in route.path: - return MCPType.TOOL, f"admin_{name}" - - # Convert user parameter routes to templates with custom naming - if "/users/{" in route.path: - return MCPType.RESOURCE_TEMPLATE, f"user_template_{name}" - - # Convert list routes to resources with custom naming - if route.path.endswith("/users") or route.path.endswith("/data"): - return MCPType.RESOURCE, f"list_{name}" - - # Use defaults for everything else + return MCPType.TOOL return None + def component_customizer(route, component): + """Add admin tag to admin components.""" + if "/admin/" in route.path: + component.tags.add("admin") + server = FastMCPOpenAPI( openapi_spec=sample_openapi_spec, client=http_client, name="Test Server", - route_map_fn=complex_mapper, + route_map_fn=route_mapper, + mcp_component_fn=component_customizer, ) - # Check that the complex logic was applied correctly + # Check that both functions worked tools = server._tool_manager._tools - resources = server._resource_manager._resources - templates = server._resource_manager._templates - # Admin routes should be tools - assert "admin_getAdminSettings" in tools - assert "admin_updateAdminSettings" in tools + # Admin GET route should be converted to tool + assert "getAdminSettings" in tools + admin_tool = tools["getAdminSettings"] + assert "admin" in admin_tool.tags - # List routes should be resources with custom names - found_list_resource = False - for uri in resources.keys(): - if "list_listUsers" in uri or "list_getData" in uri: - found_list_resource = True - break - assert found_list_resource - - # User parameter route should be template with custom name - found_user_template = False - for uri in templates.keys(): - if "user_template_getUserById" in uri: - found_user_template = True - break - assert found_user_template + # Admin POST route should have admin tag + admin_post_tool = tools["updateAdminSettings"] + assert "admin" in admin_post_tool.tags def test_route_map_fn_signature_validation(): @@ -275,10 +301,77 @@ def test_route_map_fn_signature_validation(): # This is more of a type checking test def valid_route_map_fn( - route: openapi.HTTPRoute, mcp_type: MCPType, name: str - ) -> tuple[MCPType, str] | None: + route: openapi.HTTPRoute, mcp_type: MCPType + ) -> MCPType | None: return None # Should be assignable to RouteMapFn type fn: RouteMapFn = valid_route_map_fn assert callable(fn) + + +def test_component_fn_signature_validation(): + """Test that component_fn has the correct signature.""" + from fastmcp.server.openapi import ( + ComponentFn, + OpenAPIResource, + OpenAPIResourceTemplate, + OpenAPITool, + ) + from fastmcp.utilities import openapi + + # This is more of a type checking test + def valid_component_fn( + route: openapi.HTTPRoute, + component: OpenAPITool | OpenAPIResource | OpenAPIResourceTemplate, + ) -> None: + pass + + # Should be assignable to ComponentFn type + fn: ComponentFn = valid_component_fn + assert callable(fn) + + +def test_route_map_fn_can_rescue_excluded_routes(sample_openapi_spec, http_client): + """Test that route_map_fn can rescue routes that were excluded by RouteMap.""" + + from fastmcp.server.openapi import RouteMap + + # Exclude ALL routes by default + route_maps = [ + RouteMap(mcp_type=MCPType.EXCLUDE) # Catch-all exclusion + ] + + def rescue_users_routes(route, mcp_type): + """Rescue only user-related routes.""" + if "/users" in route.path: + # Rescue user routes as tools + return MCPType.TOOL + # Let everything else stay excluded + return None + + server = FastMCPOpenAPI( + openapi_spec=sample_openapi_spec, + client=http_client, + name="Test Server", + route_maps=route_maps, + route_map_fn=rescue_users_routes, + ) + + # Only user routes should be rescued as tools + tools = server._tool_manager._tools + resources = server._resource_manager._resources + templates = server._resource_manager._templates + + # Should have user-related tools + assert "listUsers" in tools + assert "getUserById" in tools + + # Should have no resources or templates (everything excluded except rescued tools) + assert len(resources) == 0 + assert len(templates) == 0 + + # Admin and API routes should still be excluded + assert "getAdminSettings" not in tools + assert "updateAdminSettings" not in tools + assert "getData" not in tools From 59946a650318321cb53ef58a28b73484a18cc279 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 23 May 2025 11:07:49 -0400 Subject: [PATCH 073/114] Update openapi.py --- src/fastmcp/server/openapi.py | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py index 044325ae9..dfce4e21e 100644 --- a/src/fastmcp/server/openapi.py +++ b/src/fastmcp/server/openapi.py @@ -642,26 +642,12 @@ class FastMCPOpenAPI(FastMCP): ), ] - # Advanced: Custom route mapping function for fine-grained control - def custom_route_mapper(route, mcp_type, name): - # Convert all admin routes to tools regardless of HTTP method - if "/admin/" in route.path: - return MCPType.TOOL, f"admin_{name}" - - # Rename all user-specific routes to include "user_" - if "/users/{id}" in route.path: - return mcp_type, f"user_{name}" - - # Accept defaults for all other routes - return None - # Create server with custom mappings and route mapper server = FastMCPOpenAPI( openapi_spec=spec, client=httpx.AsyncClient(), name="API Server", route_maps=custom_mappings, - route_map_fn=custom_route_mapper, ) ``` """ @@ -687,8 +673,8 @@ class FastMCPOpenAPI(FastMCP): route_maps: Optional list of RouteMap objects defining route mappings route_map_fn: Optional callable for advanced route type mapping. Receives (route, mcp_type) and returns MCPType or None. - Only called on routes that matched a route_map and were not excluded. - component_fn: Optional callable for component customization. + Called on every route, including excluded ones. + mcp_component_fn: Optional callable for component customization. Receives (route, component) and can modify the component in-place. Called on every created component. timeout: Optional timeout (in seconds) for all requests From c772b4a09a82f7c7f8e63d727de1aa749992aa9c Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 23 May 2025 11:16:00 -0400 Subject: [PATCH 074/114] Add client_kwargs to be passed to from_fastapi --- src/fastmcp/server/server.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 99d779dcb..526ca9bdf 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -1186,6 +1186,7 @@ class FastMCP(Generic[LifespanResultT]): route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, all_routes_as_tools: bool = False, + httpx_client_kwargs: dict[str, Any] | None = None, **settings: Any, ) -> FastMCPOpenAPI: """ @@ -1209,8 +1210,13 @@ class FastMCP(Generic[LifespanResultT]): elif all_routes_as_tools: route_maps = [RouteMap(methods="*", pattern=r".*", mcp_type=MCPType.TOOL)] + if httpx_client_kwargs is None: + httpx_client_kwargs = {} + httpx_client_kwargs.setdefault("base_url", "http://fastapi") + client = httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), base_url="http://fastapi" + transport=httpx.ASGITransport(app=app), + **httpx_client_kwargs, ) name = name or app.title From 213abc42448e021679cae8dde460c01f025ab0da Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 23 May 2025 12:38:24 -0400 Subject: [PATCH 075/114] Pass client headers through to OpenAPI client --- src/fastmcp/server/openapi.py | 34 +++- tests/client/test_openapi.py | 157 ++++++++++++++++++ tests/deprecated/test_route_type_ignore.py | 4 +- tests/server/http/test_http_dependencies.py | 88 ++++++++-- tests/server/openapi/test_openapi.py | 48 +++--- .../utilities/openapi/test_openapi_fastapi.py | 18 +- 6 files changed, 296 insertions(+), 53 deletions(-) create mode 100644 tests/client/test_openapi.py diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py index dfce4e21e..33e6440e6 100644 --- a/src/fastmcp/server/openapi.py +++ b/src/fastmcp/server/openapi.py @@ -17,6 +17,7 @@ from pydantic.networks import AnyUrl from fastmcp.exceptions import ToolError from fastmcp.resources import Resource, ResourceTemplate +from fastmcp.server.dependencies import get_http_request from fastmcp.server.server import FastMCP from fastmcp.tools.tool import Tool, _convert_to_content from fastmcp.utilities import openapi @@ -365,6 +366,20 @@ class OpenAPITool(Tool): # Prepare headers - fix typing by ensuring all values are strings headers = {} + + # Try to get headers from the current MCP client HTTP request + try: + http_request = get_http_request() + # Add headers from the MCP client request + for name, value in http_request.headers.items(): + # Don't override headers that are already set on the client + if name not in self._client.headers: + headers[name] = str(value) + except RuntimeError: + # No active HTTP request (e.g., STDIO transport), continue without client headers + pass + + # Add any OpenAPI-defined header parameters (these take precedence over client headers) for p in self._route.parameters: if ( p.location == "header" @@ -519,10 +534,24 @@ class OpenAPIResource(Resource): if value is not None and value != "": query_params[param.name] = value + # Prepare headers from MCP client request if available + headers = {} + try: + http_request = get_http_request() + # Add headers from the MCP client request + for name, value in http_request.headers.items(): + # Don't override headers that are already set on the client + if name not in self._client.headers: + headers[name] = str(value) + except RuntimeError: + # No active HTTP request (e.g., STDIO transport), continue without client headers + pass + response = await self._client.request( method=self._route.method, url=path, params=query_params, + headers=headers, timeout=self._timeout, ) @@ -733,6 +762,7 @@ class FastMCPOpenAPI(FastMCP): ) -> str: """Generate a default name from the route path.""" # First check for OpenAPI operationId which takes precedence + if route.operation_id: return route.operation_id @@ -848,7 +878,7 @@ class FastMCPOpenAPI(FastMCP): # Get a unique resource name resource_name = self._get_unique_name(name, "resources") - resource_uri = f"resource://openapi/{resource_name}" + resource_uri = f"resource://{resource_name}" base_description = ( route.description or route.summary or f"Represents {route.path}" ) @@ -896,7 +926,7 @@ class FastMCPOpenAPI(FastMCP): path_params = [p.name for p in route.parameters if p.location == "path"] path_params.sort() # Sort for consistent URIs - uri_template_str = f"resource://openapi/{template_name}" + uri_template_str = f"resource://{template_name}" if path_params: uri_template_str += "/" + "/".join(f"{{{p}}}" for p in path_params) diff --git a/tests/client/test_openapi.py b/tests/client/test_openapi.py new file mode 100644 index 000000000..b2639e614 --- /dev/null +++ b/tests/client/test_openapi.py @@ -0,0 +1,157 @@ +import json +import sys +from collections.abc import Generator + +import pytest +import uvicorn +from fastapi import FastAPI, Request +from mcp.types import TextContent, TextResourceContents + +from fastmcp import Client, FastMCP +from fastmcp.client.transports import SSETransport, StreamableHttpTransport +from fastmcp.utilities.tests import run_server_in_process + + +def fastmcp_server_for_headers() -> FastMCP: + app = FastAPI() + + @app.get("/headers") + def get_headers(request: Request): + return request.headers + + @app.get("/headers/{header_name}") + def get_header_by_name(header_name: str, request: Request): + return request.headers[header_name] + + @app.post("/headers") + def post_headers(request: Request): + return request.headers + + mcp = FastMCP.from_fastapi( + app, httpx_client_kwargs={"headers": {"X-SERVER": "test-abc"}} + ) + + return mcp + + +class TestClientHeaders: + def run_shttp_server(self, host: str, port: int) -> None: + try: + app = fastmcp_server_for_headers().http_app(transport="streamable-http") + server = uvicorn.Server( + config=uvicorn.Config( + app=app, + host=host, + port=port, + log_level="error", + lifespan="on", + ) + ) + server.run() + except Exception as e: + print(f"Server error: {e}") + sys.exit(1) + sys.exit(0) + + def run_sse_server(self, host: str, port: int) -> None: + try: + app = fastmcp_server_for_headers().http_app(transport="sse") + server = uvicorn.Server( + config=uvicorn.Config( + app=app, + host=host, + port=port, + log_level="error", + lifespan="on", + ) + ) + server.run() + except Exception as e: + print(f"Server error: {e}") + sys.exit(1) + sys.exit(0) + + @pytest.fixture(autouse=True, scope="class") + def shttp_server(self) -> Generator[str, None, None]: + with run_server_in_process(self.run_shttp_server) as url: + yield f"{url}/mcp" + + @pytest.fixture(autouse=True, scope="class") + def sse_server(self) -> Generator[str, None, None]: + with run_server_in_process(self.run_sse_server) as url: + yield f"{url}/sse" + + async def test_client_headers_sse_resource(self, sse_server: str): + async with Client( + transport=SSETransport(sse_server, headers={"X-TEST": "test-123"}) + ) as client: + result = await client.read_resource("resource://get_headers_headers_get") + assert isinstance(result[0], TextResourceContents) + headers = json.loads(result[0].text) + assert headers["x-test"] == "test-123" + + async def test_client_headers_shttp_resource(self, shttp_server: str): + async with Client( + transport=StreamableHttpTransport( + shttp_server, headers={"X-TEST": "test-123"} + ) + ) as client: + result = await client.read_resource("resource://get_headers_headers_get") + assert isinstance(result[0], TextResourceContents) + headers = json.loads(result[0].text) + assert headers["x-test"] == "test-123" + + async def test_client_headers_sse_resource_template(self, sse_server: str): + async with Client( + transport=SSETransport(sse_server, headers={"X-TEST": "test-123"}) + ) as client: + result = await client.read_resource( + "resource://get_header_by_name_headers__header_name__get/x-test" + ) + assert isinstance(result[0], TextResourceContents) + header = json.loads(result[0].text) + assert header == "test-123" + + async def test_client_headers_shttp_resource_template(self, shttp_server: str): + async with Client( + transport=StreamableHttpTransport( + shttp_server, headers={"X-TEST": "test-123"} + ) + ) as client: + result = await client.read_resource( + "resource://get_header_by_name_headers__header_name__get/x-test" + ) + assert isinstance(result[0], TextResourceContents) + header = json.loads(result[0].text) + assert header == "test-123" + + async def test_client_headers_sse_tool(self, sse_server: str): + async with Client( + transport=SSETransport(sse_server, headers={"X-TEST": "test-123"}) + ) as client: + result = await client.call_tool("post_headers_headers_post") + assert isinstance(result[0], TextContent) + headers = json.loads(result[0].text) + assert headers["x-test"] == "test-123" + + async def test_client_headers_shttp_tool(self, shttp_server: str): + async with Client( + transport=StreamableHttpTransport( + shttp_server, headers={"X-TEST": "test-123"} + ) + ) as client: + result = await client.call_tool("post_headers_headers_post") + assert isinstance(result[0], TextContent) + headers = json.loads(result[0].text) + assert headers["x-test"] == "test-123" + + async def test_client_doesnt_override_server_headers(self, shttp_server: str): + async with Client( + transport=StreamableHttpTransport( + shttp_server, headers={"X-SERVER": "test-client"} + ) + ) as client: + result = await client.read_resource("resource://get_headers_headers_get") + assert isinstance(result[0], TextResourceContents) + headers = json.loads(result[0].text) + assert headers["x-server"] == "test-abc" diff --git a/tests/deprecated/test_route_type_ignore.py b/tests/deprecated/test_route_type_ignore.py index 1382d7137..575c0780e 100644 --- a/tests/deprecated/test_route_type_ignore.py +++ b/tests/deprecated/test_route_type_ignore.py @@ -109,5 +109,5 @@ class TestRouteTypeIgnoreDeprecation: resource_uris = [str(r.uri) for r in resources.values()] # Analytics should be excluded - assert "resource://openapi/get_items" in resource_uris - assert "resource://openapi/get_analytics" not in resource_uris + assert "resource://get_items" in resource_uris + assert "resource://get_analytics" not in resource_uris diff --git a/tests/server/http/test_http_dependencies.py b/tests/server/http/test_http_dependencies.py index 680717adb..192090792 100644 --- a/tests/server/http/test_http_dependencies.py +++ b/tests/server/http/test_http_dependencies.py @@ -7,7 +7,7 @@ import uvicorn from mcp.types import TextContent, TextResourceContents from fastmcp.client import Client -from fastmcp.client.transports import StreamableHttpTransport +from fastmcp.client.transports import SSETransport, StreamableHttpTransport from fastmcp.server.dependencies import get_http_request from fastmcp.server.server import FastMCP from fastmcp.utilities.tests import run_server_in_process @@ -41,9 +41,28 @@ def fastmcp_server(): return server -def run_server(host: str, port: int) -> None: +def run_shttp_server(host: str, port: int) -> None: try: - app = fastmcp_server().http_app() + app = fastmcp_server().http_app(transport="streamable-http") + server = uvicorn.Server( + config=uvicorn.Config( + app=app, + host=host, + port=port, + log_level="error", + lifespan="on", + ) + ) + server.run() + except Exception as e: + print(f"Server error: {e}") + sys.exit(1) + sys.exit(0) + + +def run_sse_server(host: str, port: int) -> None: + try: + app = fastmcp_server().http_app(transport="sse") server = uvicorn.Server( config=uvicorn.Config( app=app, @@ -61,15 +80,23 @@ def run_server(host: str, port: int) -> None: @pytest.fixture(autouse=True, scope="module") -def sse_server() -> Generator[str, None, None]: - with run_server_in_process(run_server) as url: +def shttp_server() -> Generator[str, None, None]: + with run_server_in_process(run_shttp_server) as url: yield f"{url}/mcp" -async def test_http_headers_resource(sse_server: str): +@pytest.fixture(autouse=True, scope="module") +def sse_server() -> Generator[str, None, None]: + with run_server_in_process(run_sse_server) as url: + yield f"{url}/sse" + + +async def test_http_headers_resource_shttp(shttp_server: str): """Test getting HTTP headers from the server.""" async with Client( - transport=StreamableHttpTransport(sse_server, headers={"X-DEMO-HEADER": "ABC"}) + transport=StreamableHttpTransport( + shttp_server, headers={"X-DEMO-HEADER": "ABC"} + ) ) as client: raw_result = await client.read_resource("request://headers") assert isinstance(raw_result[0], TextResourceContents) @@ -78,10 +105,24 @@ async def test_http_headers_resource(sse_server: str): assert json_result["x-demo-header"] == "ABC" -async def test_http_headers_tool(sse_server: str): +async def test_http_headers_resource_sse(sse_server: str): """Test getting HTTP headers from the server.""" async with Client( - transport=StreamableHttpTransport(sse_server, headers={"X-DEMO-HEADER": "ABC"}) + transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"}) + ) as client: + raw_result = await client.read_resource("request://headers") + assert isinstance(raw_result[0], TextResourceContents) + json_result = json.loads(raw_result[0].text) + assert "x-demo-header" in json_result + assert json_result["x-demo-header"] == "ABC" + + +async def test_http_headers_tool_shttp(shttp_server: str): + """Test getting HTTP headers from the server.""" + async with Client( + transport=StreamableHttpTransport( + shttp_server, headers={"X-DEMO-HEADER": "ABC"} + ) ) as client: result = await client.call_tool("get_headers_tool") assert isinstance(result[0], TextContent) @@ -90,10 +131,35 @@ async def test_http_headers_tool(sse_server: str): assert json_result["x-demo-header"] == "ABC" -async def test_http_headers_prompt(sse_server: str): +async def test_http_headers_tool_sse(sse_server: str): + async with Client( + transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"}) + ) as client: + result = await client.call_tool("get_headers_tool") + assert isinstance(result[0], TextContent) + json_result = json.loads(result[0].text) + assert "x-demo-header" in json_result + assert json_result["x-demo-header"] == "ABC" + + +async def test_http_headers_prompt_shttp(shttp_server: str): """Test getting HTTP headers from the server.""" async with Client( - transport=StreamableHttpTransport(sse_server, headers={"X-DEMO-HEADER": "ABC"}) + transport=StreamableHttpTransport( + shttp_server, headers={"X-DEMO-HEADER": "ABC"} + ) + ) as client: + result = await client.get_prompt("get_headers_prompt") + assert isinstance(result.messages[0].content, TextContent) + json_result = json.loads(result.messages[0].content.text) + assert "x-demo-header" in json_result + assert json_result["x-demo-header"] == "ABC" + + +async def test_http_headers_prompt_sse(sse_server: str): + """Test getting HTTP headers from the server.""" + async with Client( + transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"}) ) as client: result = await client.get_prompt("get_headers_prompt") assert isinstance(result.messages[0].content, TextContent) diff --git a/tests/server/openapi/test_openapi.py b/tests/server/openapi/test_openapi.py index f4d16ae08..b41c215b2 100644 --- a/tests/server/openapi/test_openapi.py +++ b/tests/server/openapi/test_openapi.py @@ -249,7 +249,7 @@ class TestTools: # Check that the user was created via MCP async with Client(fastmcp_openapi_server) as client: user_response = await client.read_resource( - "resource://openapi/get_user_users__user_id__get/4" + "resource://get_user_users__user_id__get/4" ) assert isinstance(user_response[0], TextResourceContents) response_text = user_response[0].text @@ -283,7 +283,7 @@ class TestTools: # Check that the user was updated via MCP async with Client(fastmcp_openapi_server) as client: user_response = await client.read_resource( - "resource://openapi/get_user_users__user_id__get/1" + "resource://get_user_users__user_id__get/1" ) assert isinstance(user_response[0], TextResourceContents) response_text = user_response[0].text @@ -325,7 +325,7 @@ class TestResources: async with Client(fastmcp_openapi_server) as client: resources = await client.list_resources() assert len(resources) == 4 - assert resources[0].uri == AnyUrl("resource://openapi/get_users_users_get") + assert resources[0].uri == AnyUrl("resource://get_users_users_get") assert resources[0].name == "get_users_users_get" async def test_get_resource( @@ -343,7 +343,7 @@ class TestResources: ) async with Client(fastmcp_openapi_server) as client: resource_response = await client.read_resource( - "resource://openapi/get_users_users_get" + "resource://get_users_users_get" ) assert isinstance(resource_response[0], TextResourceContents) response_text = resource_response[0].text @@ -360,7 +360,7 @@ class TestResources: """Test reading a resource that returns bytes.""" async with Client(fastmcp_openapi_server) as client: resource_response = await client.read_resource( - "resource://openapi/ping_bytes_ping_bytes_get" + "resource://ping_bytes_ping_bytes_get" ) assert isinstance(resource_response[0], BlobResourceContents) assert base64.b64decode(resource_response[0].blob) == b"pong" @@ -372,9 +372,7 @@ class TestResources: ): """Test reading a resource that returns a string.""" async with Client(fastmcp_openapi_server) as client: - resource_response = await client.read_resource( - "resource://openapi/ping_ping_get" - ) + resource_response = await client.read_resource("resource://ping_ping_get") assert isinstance(resource_response[0], TextResourceContents) assert resource_response[0].text == "pong" @@ -392,7 +390,7 @@ class TestResourceTemplates: assert resource_templates[0].name == "get_user_users__user_id__get" assert ( resource_templates[0].uriTemplate - == r"resource://openapi/get_user_users__user_id__get/{user_id}" + == r"resource://get_user_users__user_id__get/{user_id}" ) assert ( resource_templates[1].name @@ -400,7 +398,7 @@ class TestResourceTemplates: ) assert ( resource_templates[1].uriTemplate - == r"resource://openapi/get_user_active_state_users__user_id___is_active__get/{is_active}/{user_id}" + == r"resource://get_user_active_state_users__user_id___is_active__get/{is_active}/{user_id}" ) async def test_get_resource_template( @@ -415,7 +413,7 @@ class TestResourceTemplates: user_id = 2 async with Client(fastmcp_openapi_server) as client: resource_response = await client.read_resource( - f"resource://openapi/get_user_users__user_id__get/{user_id}" + f"resource://get_user_users__user_id__get/{user_id}" ) assert isinstance(resource_response[0], TextResourceContents) response_text = resource_response[0].text @@ -438,7 +436,7 @@ class TestResourceTemplates: is_active = True async with Client(fastmcp_openapi_server) as client: resource_response = await client.read_resource( - f"resource://openapi/get_user_active_state_users__user_id___is_active__get/{is_active}/{user_id}" + f"resource://get_user_active_state_users__user_id___is_active__get/{is_active}/{user_id}" ) assert isinstance(resource_response[0], TextResourceContents) response_text = resource_response[0].text @@ -555,7 +553,7 @@ class TestTagTransfer: # Manually create a resource from template params = {"user_id": 1} resource = await get_user_template.create_resource( - "resource://openapi/get_user_users__user_id__get/1", params + "resource://get_user_users__user_id__get/1", params ) # Verify tags are preserved from template to resource @@ -672,7 +670,7 @@ class TestOpenAPI30Compatibility: async with Client(openapi_30_server) as client: resources = await client.list_resources() assert len(resources) == 1 - assert resources[0].uri == AnyUrl("resource://openapi/listProducts") + assert resources[0].uri == AnyUrl("resource://listProducts") async def test_resource_template_discovery(self, openapi_30_server): """Test that resource templates are correctly discovered from an OpenAPI 3.0 spec.""" @@ -680,7 +678,7 @@ class TestOpenAPI30Compatibility: templates = await client.list_resource_templates() assert len(templates) == 1 assert templates[0].name == "getProduct" - assert templates[0].uriTemplate == r"resource://openapi/getProduct/{product_id}" + assert templates[0].uriTemplate == r"resource://getProduct/{product_id}" async def test_tool_discovery(self, openapi_30_server): """Test that tools are correctly discovered from an OpenAPI 3.0 spec.""" @@ -694,9 +692,7 @@ class TestOpenAPI30Compatibility: async def test_resource_access(self, openapi_30_server): """Test reading a resource from an OpenAPI 3.0 server.""" async with Client(openapi_30_server) as client: - resource_response = await client.read_resource( - "resource://openapi/listProducts" - ) + resource_response = await client.read_resource("resource://listProducts") assert isinstance(resource_response[0], TextResourceContents) response_text = resource_response[0].text content = json.loads(response_text) @@ -707,9 +703,7 @@ class TestOpenAPI30Compatibility: async def test_resource_template_access(self, openapi_30_server): """Test reading a resource from template from an OpenAPI 3.0 server.""" async with Client(openapi_30_server) as client: - resource_response = await client.read_resource( - "resource://openapi/getProduct/p1" - ) + resource_response = await client.read_resource("resource://getProduct/p1") assert isinstance(resource_response[0], TextResourceContents) response_text = resource_response[0].text content = json.loads(response_text) @@ -852,7 +846,7 @@ class TestOpenAPI31Compatibility: async with Client(openapi_31_server) as client: resources = await client.list_resources() assert len(resources) == 1 - assert resources[0].uri == AnyUrl("resource://openapi/listOrders") + assert resources[0].uri == AnyUrl("resource://listOrders") async def test_resource_template_discovery(self, openapi_31_server): """Test that resource templates are correctly discovered from an OpenAPI 3.1 spec.""" @@ -860,7 +854,7 @@ class TestOpenAPI31Compatibility: templates = await client.list_resource_templates() assert len(templates) == 1 assert templates[0].name == "getOrder" - assert templates[0].uriTemplate == r"resource://openapi/getOrder/{order_id}" + assert templates[0].uriTemplate == r"resource://getOrder/{order_id}" async def test_tool_discovery(self, openapi_31_server): """Test that tools are correctly discovered from an OpenAPI 3.1 spec.""" @@ -874,9 +868,7 @@ class TestOpenAPI31Compatibility: async def test_resource_access(self, openapi_31_server): """Test reading a resource from an OpenAPI 3.1 server.""" async with Client(openapi_31_server) as client: - resource_response = await client.read_resource( - "resource://openapi/listOrders" - ) + resource_response = await client.read_resource("resource://listOrders") assert isinstance(resource_response[0], TextResourceContents) response_text = resource_response[0].text content = json.loads(response_text) @@ -887,9 +879,7 @@ class TestOpenAPI31Compatibility: async def test_resource_template_access(self, openapi_31_server): """Test reading a resource from template from an OpenAPI 3.1 server.""" async with Client(openapi_31_server) as client: - resource_response = await client.read_resource( - "resource://openapi/getOrder/o1" - ) + resource_response = await client.read_resource("resource://getOrder/o1") assert isinstance(resource_response[0], TextResourceContents) response_text = resource_response[0].text content = json.loads(response_text) diff --git a/tests/utilities/openapi/test_openapi_fastapi.py b/tests/utilities/openapi/test_openapi_fastapi.py index 3f0ee8b41..9b7951ec0 100644 --- a/tests/utilities/openapi/test_openapi_fastapi.py +++ b/tests/utilities/openapi/test_openapi_fastapi.py @@ -9,7 +9,7 @@ from fastmcp.utilities.openapi import parse_openapi_to_http_routes @pytest.fixture -def fastapi_server() -> FastAPI: +def fastapi_app() -> FastAPI: """Fixture that returns a FastAPI app for live OpenAPI schema testing.""" from enum import Enum @@ -228,9 +228,9 @@ def fastapi_server() -> FastAPI: @pytest.fixture -def fastapi_openapi_schema(fastapi_server) -> dict[str, Any]: +def fastapi_openapi_schema(fastapi_app) -> dict[str, Any]: """Fixture that returns the OpenAPI schema from a live FastAPI server.""" - return fastapi_server.openapi() + return fastapi_app.openapi() @pytest.fixture @@ -472,11 +472,11 @@ def test_tag_consistency_across_related_endpoints(route_map): ) -def test_tag_order_preservation(fastapi_server): +def test_tag_order_preservation(fastapi_app): """Test that tag order is preserved in the parsed routes.""" # Add a new endpoint with specifically ordered tags - @fastapi_server.get( + @fastapi_app.get( "/test-tag-order", tags=["first", "second", "third"], operation_id="test_tag_order", @@ -485,7 +485,7 @@ def test_tag_order_preservation(fastapi_server): return {"result": "testing tag order"} # Get the updated schema and parse routes - routes = parse_openapi_to_http_routes(fastapi_server.openapi()) + routes = parse_openapi_to_http_routes(fastapi_app.openapi()) # Find our test route test_route = next((r for r in routes if r.path == "/test-tag-order"), None) @@ -497,11 +497,11 @@ def test_tag_order_preservation(fastapi_server): ) -def test_duplicate_tags_handling(fastapi_server): +def test_duplicate_tags_handling(fastapi_app): """Test handling of duplicate tags in the OpenAPI schema.""" # Add an endpoint with duplicate tags - @fastapi_server.get( + @fastapi_app.get( "/test-duplicate-tags", tags=["duplicate", "items", "duplicate"], operation_id="test_duplicate_tags", @@ -510,7 +510,7 @@ def test_duplicate_tags_handling(fastapi_server): return {"result": "testing duplicate tags"} # Get the updated schema and parse routes - routes = parse_openapi_to_http_routes(fastapi_server.openapi()) + routes = parse_openapi_to_http_routes(fastapi_app.openapi()) # Find our test route test_route = next((r for r in routes if r.path == "/test-duplicate-tags"), None) From b8e918a86248d56d091d8b8025673f9402d577d2 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 23 May 2025 15:39:45 -0400 Subject: [PATCH 076/114] Ensure headers are passed through proxy servers --- src/fastmcp/client/transports.py | 48 ++++++++++++++++++++++++++------ tests/client/test_openapi.py | 36 ++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 10 deletions(-) diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index f82b238d0..ff2c5ded3 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -25,6 +25,7 @@ from pydantic import AnyUrl from typing_extensions import Unpack from fastmcp.server import FastMCP as FastMCPServer +from fastmcp.server.dependencies import get_http_request from fastmcp.server.server import FastMCP from fastmcp.utilities.logging import get_logger from fastmcp.utilities.mcp_config import MCPConfig, infer_transport_type_from_url @@ -34,6 +35,11 @@ if TYPE_CHECKING: logger = get_logger(__name__) +EXCLUDE_HEADERS = { + "content-type", + "content-length", +} + class SessionKwargs(TypedDict, total=False): """Keyword arguments for the MCP ClientSession constructor.""" @@ -132,7 +138,21 @@ class SSETransport(ClientTransport): async def connect_session( self, **session_kwargs: Unpack[SessionKwargs] ) -> AsyncIterator[ClientSession]: - client_kwargs = {} + client_kwargs: dict[str, Any] = { + "headers": self.headers, + } + + # load headers from an active HTTP request, if available. This will only be true + # if the client is used in a FastMCP Proxy, in which case the MCP client headers + # need to be forwarded to the remote server. + try: + active_request = get_http_request() + for name, value in active_request.headers.items(): + if name not in self.headers and name not in EXCLUDE_HEADERS: + client_kwargs["headers"][name] = str(value) + except RuntimeError: + client_kwargs["headers"] = self.headers + # sse_read_timeout has a default value set, so we can't pass None without overriding it # instead we simply leave the kwarg out if it's not provided if self.sse_read_timeout is not None: @@ -143,9 +163,7 @@ class SSETransport(ClientTransport): ) client_kwargs["timeout"] = read_timeout_seconds.total_seconds() - async with sse_client( - self.url, headers=self.headers, **client_kwargs - ) as transport: + async with sse_client(self.url, **client_kwargs) as transport: read_stream, write_stream = transport async with ClientSession( read_stream, write_stream, **session_kwargs @@ -180,7 +198,23 @@ class StreamableHttpTransport(ClientTransport): async def connect_session( self, **session_kwargs: Unpack[SessionKwargs] ) -> AsyncIterator[ClientSession]: - client_kwargs = {} + client_kwargs: dict[str, Any] = { + "headers": self.headers, + } + + # load headers from an active HTTP request, if available. This will only be true + # if the client is used in a FastMCP Proxy, in which case the MCP client headers + # need to be forwarded to the remote server. + try: + active_request = get_http_request() + for name, value in active_request.headers.items(): + if name not in self.headers and name not in EXCLUDE_HEADERS: + client_kwargs["headers"][name] = str(value) + + except RuntimeError: + client_kwargs["headers"] = self.headers + print(client_kwargs) + # sse_read_timeout has a default value set, so we can't pass None without overriding it # instead we simply leave the kwarg out if it's not provided if self.sse_read_timeout is not None: @@ -188,9 +222,7 @@ class StreamableHttpTransport(ClientTransport): if session_kwargs.get("read_timeout_seconds", None) is not None: client_kwargs["timeout"] = session_kwargs.get("read_timeout_seconds") - async with streamablehttp_client( - self.url, headers=self.headers, **client_kwargs - ) as transport: + async with streamablehttp_client(self.url, **client_kwargs) as transport: read_stream, write_stream, _ = transport async with ClientSession( read_stream, write_stream, **session_kwargs diff --git a/tests/client/test_openapi.py b/tests/client/test_openapi.py index b2639e614..5ee33ce7e 100644 --- a/tests/client/test_openapi.py +++ b/tests/client/test_openapi.py @@ -71,16 +71,40 @@ class TestClientHeaders: sys.exit(1) sys.exit(0) - @pytest.fixture(autouse=True, scope="class") + def run_proxy_server(self, host: str, port: int, remote_url: str) -> None: + try: + client = Client(transport=StreamableHttpTransport(remote_url)) + app = FastMCP.as_proxy(client).http_app(transport="streamable-http") + server = uvicorn.Server( + config=uvicorn.Config( + app=app, + host=host, + port=port, + log_level="error", + lifespan="on", + ) + ) + server.run() + except Exception as e: + print(f"Server error: {e}") + sys.exit(1) + sys.exit(0) + + @pytest.fixture(scope="class") def shttp_server(self) -> Generator[str, None, None]: with run_server_in_process(self.run_shttp_server) as url: yield f"{url}/mcp" - @pytest.fixture(autouse=True, scope="class") + @pytest.fixture(scope="class") def sse_server(self) -> Generator[str, None, None]: with run_server_in_process(self.run_sse_server) as url: yield f"{url}/sse" + @pytest.fixture(scope="class") + def proxy_server(self, shttp_server: str) -> Generator[str, None, None]: + with run_server_in_process(self.run_proxy_server, shttp_server + "/mcp") as url: + yield f"{url}/mcp" + async def test_client_headers_sse_resource(self, sse_server: str): async with Client( transport=SSETransport(sse_server, headers={"X-TEST": "test-123"}) @@ -155,3 +179,11 @@ class TestClientHeaders: assert isinstance(result[0], TextResourceContents) headers = json.loads(result[0].text) assert headers["x-server"] == "test-abc" + + async def test_client_headers_proxy(self, proxy_server: str): + async with Client(transport=StreamableHttpTransport(proxy_server)) as client: + await client.ping() + result = await client.read_resource("resource://get_headers_headers_get") + assert isinstance(result[0], TextResourceContents) + headers = json.loads(result[0].text) + assert headers["x-server"] == "test-abc" From de666be70de48d95915443a1d47b6b6bb843f5dc Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 23 May 2025 15:40:03 -0400 Subject: [PATCH 077/114] Update test_openapi.py --- tests/client/test_openapi.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/client/test_openapi.py b/tests/client/test_openapi.py index 5ee33ce7e..903582f59 100644 --- a/tests/client/test_openapi.py +++ b/tests/client/test_openapi.py @@ -181,6 +181,9 @@ class TestClientHeaders: assert headers["x-server"] == "test-abc" async def test_client_headers_proxy(self, proxy_server: str): + """ + Test that client headers are passed through the proxy to the remove server. + """ async with Client(transport=StreamableHttpTransport(proxy_server)) as client: await client.ping() result = await client.read_resource("resource://get_headers_headers_get") From 9350d558c63cf93bfa36eaf642b5e002c333acae Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 23 May 2025 15:42:56 -0400 Subject: [PATCH 078/114] Update test_openapi.py --- tests/server/openapi/test_openapi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/server/openapi/test_openapi.py b/tests/server/openapi/test_openapi.py index b41c215b2..89ff261ba 100644 --- a/tests/server/openapi/test_openapi.py +++ b/tests/server/openapi/test_openapi.py @@ -917,7 +917,7 @@ class TestMountFastMCP: assert len(resources) == 4 # Updated to account for new search endpoint # We're checking the key used by mcp to store the resource # The prefixed URI is used as the key, but the resource's original uri is preserved - prefixed_uri = "resource://fastapi/openapi/get_users_users_get" + prefixed_uri = "resource://fastapi/get_users_users_get" resource = mcp._resource_manager.get_resources().get(prefixed_uri) assert resource is not None From e0ae84c75f9a8841ffa7db669f2cab7f3a74bc2f Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 23 May 2025 16:02:42 -0400 Subject: [PATCH 079/114] Use lowercase name for headers --- src/fastmcp/client/transports.py | 2 ++ src/fastmcp/server/openapi.py | 59 ++++++++++++++++---------------- tests/client/test_openapi.py | 4 +-- 3 files changed, 34 insertions(+), 31 deletions(-) diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index ff2c5ded3..5720d7fd6 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -148,6 +148,7 @@ class SSETransport(ClientTransport): try: active_request = get_http_request() for name, value in active_request.headers.items(): + name = name.lower() if name not in self.headers and name not in EXCLUDE_HEADERS: client_kwargs["headers"][name] = str(value) except RuntimeError: @@ -208,6 +209,7 @@ class StreamableHttpTransport(ClientTransport): try: active_request = get_http_request() for name, value in active_request.headers.items(): + name = name.lower() if name not in self.headers and name not in EXCLUDE_HEADERS: client_kwargs["headers"][name] = str(value) diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py index 33e6440e6..b964f34d1 100644 --- a/src/fastmcp/server/openapi.py +++ b/src/fastmcp/server/openapi.py @@ -35,6 +35,26 @@ logger = get_logger(__name__) HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"] + +def _get_mcp_client_headers() -> dict[str, str]: + """ + Extract headers from the current MCP client HTTP request if available. + + These headers will take precedence over OpenAPI-defined headers when both are present. + + Returns: + Dictionary of header name-value pairs (lowercased names), or empty dict if no HTTP request is active. + """ + try: + http_request = get_http_request() + return { + name.lower(): str(value) for name, value in http_request.headers.items() + } + except RuntimeError: + # No active HTTP request (e.g., STDIO transport), return empty dict + return {} + + # Type definitions for the mapping functions RouteMapFn = Callable[[HTTPRoute, "MCPType"], "MCPType | None"] ComponentFn = Callable[ @@ -367,26 +387,20 @@ class OpenAPITool(Tool): # Prepare headers - fix typing by ensuring all values are strings headers = {} - # Try to get headers from the current MCP client HTTP request - try: - http_request = get_http_request() - # Add headers from the MCP client request - for name, value in http_request.headers.items(): - # Don't override headers that are already set on the client - if name not in self._client.headers: - headers[name] = str(value) - except RuntimeError: - # No active HTTP request (e.g., STDIO transport), continue without client headers - pass - - # Add any OpenAPI-defined header parameters (these take precedence over client headers) + # Start with OpenAPI-defined header parameters + openapi_headers = {} for p in self._route.parameters: if ( p.location == "header" and p.name in kwargs and kwargs[p.name] is not None ): - headers[p.name] = str(kwargs[p.name]) + openapi_headers[p.name.lower()] = str(kwargs[p.name]) + headers.update(openapi_headers) + + # Add headers from the current MCP client HTTP request (these take precedence) + mcp_headers = _get_mcp_client_headers() + headers.update(mcp_headers) # Prepare request body json_data = None @@ -536,16 +550,8 @@ class OpenAPIResource(Resource): # Prepare headers from MCP client request if available headers = {} - try: - http_request = get_http_request() - # Add headers from the MCP client request - for name, value in http_request.headers.items(): - # Don't override headers that are already set on the client - if name not in self._client.headers: - headers[name] = str(value) - except RuntimeError: - # No active HTTP request (e.g., STDIO transport), continue without client headers - pass + mcp_headers = _get_mcp_client_headers() + headers.update(mcp_headers) response = await self._client.request( method=self._route.method, @@ -991,8 +997,3 @@ class FastMCPOpenAPI(FastMCP): logger.debug( f"Registered TEMPLATE: {uri_template_str} ({route.method} {route.path}) with tags: {route.tags}" ) - - 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.""" - result = await self._tool_manager.call_tool(name, arguments) - return result diff --git a/tests/client/test_openapi.py b/tests/client/test_openapi.py index 903582f59..2344a47f1 100644 --- a/tests/client/test_openapi.py +++ b/tests/client/test_openapi.py @@ -169,7 +169,7 @@ class TestClientHeaders: headers = json.loads(result[0].text) assert headers["x-test"] == "test-123" - async def test_client_doesnt_override_server_headers(self, shttp_server: str): + async def test_client_overrides_server_headers(self, shttp_server: str): async with Client( transport=StreamableHttpTransport( shttp_server, headers={"X-SERVER": "test-client"} @@ -178,7 +178,7 @@ class TestClientHeaders: result = await client.read_resource("resource://get_headers_headers_get") assert isinstance(result[0], TextResourceContents) headers = json.loads(result[0].text) - assert headers["x-server"] == "test-abc" + assert headers["x-server"] == "test-client" async def test_client_headers_proxy(self, proxy_server: str): """ From 9c3e90f01880f332c5ecc1b234eba36dc760b2b7 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 23 May 2025 16:11:56 -0400 Subject: [PATCH 080/114] Update transports.py --- src/fastmcp/client/transports.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 5720d7fd6..398c080fc 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -35,8 +35,8 @@ if TYPE_CHECKING: logger = get_logger(__name__) +# these headers, when forwarded to the remote server, can cause issues EXCLUDE_HEADERS = { - "content-type", "content-length", } @@ -149,7 +149,9 @@ class SSETransport(ClientTransport): active_request = get_http_request() for name, value in active_request.headers.items(): name = name.lower() - if name not in self.headers and name not in EXCLUDE_HEADERS: + if name not in self.headers and name not in { + h.lower() for h in EXCLUDE_HEADERS + }: client_kwargs["headers"][name] = str(value) except RuntimeError: client_kwargs["headers"] = self.headers @@ -210,7 +212,9 @@ class StreamableHttpTransport(ClientTransport): active_request = get_http_request() for name, value in active_request.headers.items(): name = name.lower() - if name not in self.headers and name not in EXCLUDE_HEADERS: + if name not in self.headers and name not in { + h.lower() for h in EXCLUDE_HEADERS + }: client_kwargs["headers"][name] = str(value) except RuntimeError: From 157e5e15867a97bee6700b350b33e57145e89ebb Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 23 May 2025 17:10:39 -0400 Subject: [PATCH 081/114] Update name generation --- src/fastmcp/server/openapi.py | 107 ++++++++++++++++++++-------------- src/fastmcp/server/server.py | 4 ++ 2 files changed, 67 insertions(+), 44 deletions(-) diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py index b964f34d1..9fff30bce 100644 --- a/src/fastmcp/server/openapi.py +++ b/src/fastmcp/server/openapi.py @@ -6,6 +6,7 @@ import enum import json import re import warnings +from collections import Counter from collections.abc import Callable from dataclasses import dataclass, field from re import Pattern @@ -36,6 +37,26 @@ logger = get_logger(__name__) HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"] +def _slugify(text: str) -> str: + """Convert text to a URL-friendly slug format.""" + if not text: + return "" + + # Replace spaces and common separators with underscores + slug = re.sub(r"[\s\-\.]+", "_", text) + + # Remove non-alphanumeric characters except underscores + slug = re.sub(r"[^a-zA-Z0-9_]", "", slug) + + # Remove multiple consecutive underscores + slug = re.sub(r"_+", "_", slug) + + # Remove leading/trailing underscores + slug = slug.strip("_") + + return slug + + def _get_mcp_client_headers() -> dict[str, str]: """ Extract headers from the current MCP client HTTP request if available. @@ -695,6 +716,7 @@ class FastMCPOpenAPI(FastMCP): route_maps: list[RouteMap] | None = None, route_map_fn: RouteMapFn | None = None, mcp_component_fn: ComponentFn | None = None, + mcp_names: dict[str, str] | None = None, timeout: float | None = None, **settings: Any, ): @@ -712,6 +734,11 @@ class FastMCPOpenAPI(FastMCP): mcp_component_fn: Optional callable for component customization. Receives (route, component) and can modify the component in-place. Called on every created component. + mcp_names: Optional dictionary mapping operationId to desired component names. + If an operationId is not in the dictionary, falls back to using the + operationId up to the first double underscore. If no operationId exists, + falls back to slugified summary or path-based naming. + All names are truncated to 56 characters maximum. timeout: Optional timeout (in seconds) for all requests **settings: Additional settings for FastMCP """ @@ -721,9 +748,15 @@ class FastMCPOpenAPI(FastMCP): self._timeout = timeout self._route_map_fn = route_map_fn self._mcp_component_fn = mcp_component_fn + self._mcp_names = mcp_names or {} # Keep track of names to detect collisions - self._used_names = {"tools": set(), "resources": set(), "templates": set()} + self._used_names = { + "tool": Counter(), + "resource": Counter(), + "resource_template": Counter(), + "prompt": Counter(), + } http_routes = openapi.parse_openapi_to_http_routes(openapi_spec) @@ -766,40 +799,31 @@ class FastMCPOpenAPI(FastMCP): def _generate_default_name( self, route: openapi.HTTPRoute, mcp_type: MCPType ) -> str: - """Generate a default name from the route path.""" - # First check for OpenAPI operationId which takes precedence + """Generate a default name from the route using the configured strategy.""" + name = "" + # First check if there's a custom mapping for this operationId if route.operation_id: - return route.operation_id - - # For path-based naming, clean up the path - path_parts = route.path.strip("/").split("/") - - # Remove path parameters (parts with {}) - clean_parts = [] - for part in path_parts: - if part.startswith("{") and part.endswith("}"): - # For templates, include parameter name without braces - if mcp_type == MCPType.RESOURCE_TEMPLATE: - param_name = part[1:-1] # Remove braces - clean_parts.append(param_name) + if route.operation_id in self._mcp_names: + name = self._mcp_names[route.operation_id] else: - clean_parts.append(part) + # If there's a double underscore, use the first part + name = route.operation_id.split("__")[0] + else: + name = route.summary or f"{route.method}_{route.path}" - # Join the parts - resource_name = "_".join(clean_parts) + name = _slugify(name) - # For tools, might be useful to keep the method for clarity on what it does - if mcp_type == MCPType.TOOL: - # Only include method if it helps distinguish (POST, PUT, PATCH, DELETE) - # For GET we don't need the method as it's implied for resources - if route.method != "GET": - resource_name = f"{route.method.lower()}_{resource_name}" + # Truncate to 56 characters maximum + if len(name) > 56: + name = name[:56] - return resource_name + return name def _get_unique_name( - self, name: str, component_type: Literal["tools", "resources", "templates"] + self, + name: str, + component_type: Literal["tool", "resource", "resource_template", "prompt"], ) -> str: """ Ensure the name is unique within its component type by appending numbers if needed. @@ -812,23 +836,18 @@ class FastMCPOpenAPI(FastMCP): str: A unique name for the component """ # Check if the name is already used - if name not in self._used_names[component_type]: - self._used_names[component_type].add(name) + self._used_names[component_type][name] += 1 + if self._used_names[component_type][name] == 1: return name - # Find the next available number suffix - counter = 2 - while f"{name}_{counter}" in self._used_names[component_type]: - counter += 1 + else: + # Create the new name + new_name = f"{name}_{self._used_names[component_type][name]}" + logger.debug( + f"Name collision detected: '{name}' already exists as a {component_type[:-1]}. " + f"Using '{new_name}' instead." + ) - # Create the new name - new_name = f"{name}_{counter}" - logger.debug( - f"Name collision detected: '{name}' already exists as a {component_type[:-1]}. " - f"Using '{new_name}' instead." - ) - - self._used_names[component_type].add(new_name) return new_name def _create_openapi_tool(self, route: openapi.HTTPRoute, name: str): @@ -836,7 +855,7 @@ class FastMCPOpenAPI(FastMCP): combined_schema = _combine_schemas(route) # Get a unique tool name - tool_name = self._get_unique_name(name, "tools") + tool_name = self._get_unique_name(name, "tool") base_description = ( route.description @@ -882,7 +901,7 @@ class FastMCPOpenAPI(FastMCP): def _create_openapi_resource(self, route: openapi.HTTPRoute, name: str): """Creates and registers an OpenAPIResource with enhanced description.""" # Get a unique resource name - resource_name = self._get_unique_name(name, "resources") + resource_name = self._get_unique_name(name, "resource") resource_uri = f"resource://{resource_name}" base_description = ( @@ -927,7 +946,7 @@ class FastMCPOpenAPI(FastMCP): def _create_openapi_template(self, route: openapi.HTTPRoute, name: str): """Creates and registers an OpenAPIResourceTemplate with enhanced description.""" # Get a unique template name - template_name = self._get_unique_name(name, "templates") + template_name = self._get_unique_name(name, "resource_template") path_params = [p.name for p in route.parameters if p.location == "path"] path_params.sort() # Sort for consistent URIs diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 43f06742e..89d760370 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -1170,6 +1170,7 @@ class FastMCP(Generic[LifespanResultT]): route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, + mcp_names: dict[str, str] | None = None, all_routes_as_tools: bool = False, **settings: Any, ) -> FastMCPOpenAPI: @@ -1199,6 +1200,7 @@ class FastMCP(Generic[LifespanResultT]): route_maps=route_maps, route_map_fn=route_map_fn, mcp_component_fn=mcp_component_fn, + mcp_names=mcp_names, **settings, ) @@ -1210,6 +1212,7 @@ class FastMCP(Generic[LifespanResultT]): route_maps: list[RouteMap] | None = None, route_map_fn: OpenAPIRouteMapFn | None = None, mcp_component_fn: OpenAPIComponentFn | None = None, + mcp_names: dict[str, str] | None = None, all_routes_as_tools: bool = False, httpx_client_kwargs: dict[str, Any] | None = None, **settings: Any, @@ -1253,6 +1256,7 @@ class FastMCP(Generic[LifespanResultT]): route_maps=route_maps, route_map_fn=route_map_fn, mcp_component_fn=mcp_component_fn, + mcp_names=mcp_names, **settings, ) From ae960e2f5a2e33857d6b7761a203579f51ea86af Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 23 May 2025 17:16:32 -0400 Subject: [PATCH 082/114] Update tests for new names --- tests/client/test_openapi.py | 4 +- tests/server/openapi/test_openapi.py | 64 ++++++------------- .../openapi/test_openapi_path_parameters.py | 10 +-- 3 files changed, 28 insertions(+), 50 deletions(-) diff --git a/tests/client/test_openapi.py b/tests/client/test_openapi.py index 2344a47f1..fe4281007 100644 --- a/tests/client/test_openapi.py +++ b/tests/client/test_openapi.py @@ -130,7 +130,7 @@ class TestClientHeaders: transport=SSETransport(sse_server, headers={"X-TEST": "test-123"}) ) as client: result = await client.read_resource( - "resource://get_header_by_name_headers__header_name__get/x-test" + "resource://get_header_by_name_headers/x-test" ) assert isinstance(result[0], TextResourceContents) header = json.loads(result[0].text) @@ -143,7 +143,7 @@ class TestClientHeaders: ) ) as client: result = await client.read_resource( - "resource://get_header_by_name_headers__header_name__get/x-test" + "resource://get_header_by_name_headers/x-test" ) assert isinstance(result[0], TextResourceContents) header = json.loads(result[0].text) diff --git a/tests/server/openapi/test_openapi.py b/tests/server/openapi/test_openapi.py index 89ff261ba..6eed469b5 100644 --- a/tests/server/openapi/test_openapi.py +++ b/tests/server/openapi/test_openapi.py @@ -208,7 +208,7 @@ class TestTools: }, ) assert tools[1].model_dump() == dict( - name="update_user_name_users__user_id__name_patch", + name="update_user_name_users", annotations=None, description=IsStr( regex=r"^Update a user's name\..*$", regex_flags=re.DOTALL @@ -248,9 +248,7 @@ class TestTools: # Check that the user was created via MCP async with Client(fastmcp_openapi_server) as client: - user_response = await client.read_resource( - "resource://get_user_users__user_id__get/4" - ) + user_response = await client.read_resource("resource://get_user_users/4") assert isinstance(user_response[0], TextResourceContents) response_text = user_response[0].text user = json.loads(response_text) @@ -264,7 +262,7 @@ class TestTools: """ async with Client(fastmcp_openapi_server) as client: tool_response = await client.call_tool( - "update_user_name_users__user_id__name_patch", + "update_user_name_users", {"user_id": 1, "name": "XYZ"}, ) @@ -282,9 +280,7 @@ class TestTools: # Check that the user was updated via MCP async with Client(fastmcp_openapi_server) as client: - user_response = await client.read_resource( - "resource://get_user_users__user_id__get/1" - ) + user_response = await client.read_resource("resource://get_user_users/1") assert isinstance(user_response[0], TextResourceContents) response_text = user_response[0].text user = json.loads(response_text) @@ -387,18 +383,14 @@ class TestResourceTemplates: async with Client(fastmcp_openapi_server) as client: resource_templates = await client.list_resource_templates() assert len(resource_templates) == 2 - assert resource_templates[0].name == "get_user_users__user_id__get" + assert resource_templates[0].name == "get_user_users" assert ( - resource_templates[0].uriTemplate - == r"resource://get_user_users__user_id__get/{user_id}" - ) - assert ( - resource_templates[1].name - == "get_user_active_state_users__user_id___is_active__get" + resource_templates[0].uriTemplate == r"resource://get_user_users/{user_id}" ) + assert resource_templates[1].name == "get_user_active_state_users" assert ( resource_templates[1].uriTemplate - == r"resource://get_user_active_state_users__user_id___is_active__get/{is_active}/{user_id}" + == r"resource://get_user_active_state_users/{is_active}/{user_id}" ) async def test_get_resource_template( @@ -413,7 +405,7 @@ class TestResourceTemplates: user_id = 2 async with Client(fastmcp_openapi_server) as client: resource_response = await client.read_resource( - f"resource://get_user_users__user_id__get/{user_id}" + f"resource://get_user_users/{user_id}" ) assert isinstance(resource_response[0], TextResourceContents) response_text = resource_response[0].text @@ -436,7 +428,7 @@ class TestResourceTemplates: is_active = True async with Client(fastmcp_openapi_server) as client: resource_response = await client.read_resource( - f"resource://get_user_active_state_users__user_id___is_active__get/{is_active}/{user_id}" + f"resource://get_user_active_state_users/{is_active}/{user_id}" ) assert isinstance(resource_response[0], TextResourceContents) response_text = resource_response[0].text @@ -472,11 +464,7 @@ class TestTagTransfer: (t for t in tools if t.name == "create_user_users_post"), None ) update_user_tool = next( - ( - t - for t in tools - if t.name == "update_user_name_users__user_id__name_patch" - ), + (t for t in tools if t.name == "update_user_name_users"), None, ) @@ -524,7 +512,7 @@ class TestTagTransfer: # Find the get_user template get_user_template = next( - (t for t in templates if t.name == "get_user_users__user_id__get"), None + (t for t in templates if t.name == "get_user_users"), None ) assert get_user_template is not None @@ -545,7 +533,7 @@ class TestTagTransfer: # Find the get_user template get_user_template = next( - (t for t in templates if t.name == "get_user_users__user_id__get"), None + (t for t in templates if t.name == "get_user_users"), None ) assert get_user_template is not None @@ -553,7 +541,7 @@ class TestTagTransfer: # Manually create a resource from template params = {"user_id": 1} resource = await get_user_template.create_resource( - "resource://get_user_users__user_id__get/1", params + "resource://get_user_users/1", params ) # Verify tags are preserved from template to resource @@ -997,7 +985,7 @@ async def test_none_path_parameters_rejected( # get_user has a required path parameter user_id with pytest.raises(ToolError, match="Missing required path parameters"): await client.call_tool( - "update_user_name_users__user_id__name_patch", + "update_user_name_users", { "user_id": None, # This should cause an error "name": "New Name", @@ -1560,9 +1548,7 @@ class TestFastAPIDescriptionPropagation: async def test_template_includes_function_docstring(self, fastapi_server): """Test that a ResourceTemplate includes the function docstring.""" templates = list(fastapi_server._resource_manager.get_templates().values()) - get_template = next( - (t for t in templates if "items__item_id__get" in t.name), None - ) + get_template = next((t for t in templates if "get_item_items" in t.name), None) assert get_template is not None, "GET /items/{item_id} template wasn't created" description = get_template.description or "" @@ -1577,9 +1563,7 @@ class TestFastAPIDescriptionPropagation: are not properly propagated to the OpenAPI schema. The parameters appear but without the description. """ templates = list(fastapi_server._resource_manager.get_templates().values()) - get_template = next( - (t for t in templates if "items__item_id__get" in t.name), None - ) + get_template = next((t for t in templates if "get_item_items" in t.name), None) assert get_template is not None, "GET /items/{item_id} template wasn't created" description = get_template.description or "" @@ -1599,9 +1583,7 @@ class TestFastAPIDescriptionPropagation: are not properly propagated to the OpenAPI schema. The parameters appear but without the description. """ templates = list(fastapi_server._resource_manager.get_templates().values()) - get_template = next( - (t for t in templates if "items__item_id__get" in t.name), None - ) + get_template = next((t for t in templates if "get_item_items" in t.name), None) assert get_template is not None, "GET /items/{item_id} template wasn't created" description = get_template.description or "" @@ -1617,9 +1599,7 @@ class TestFastAPIDescriptionPropagation: async def test_template_parameter_schema_includes_description(self, fastapi_server): """Test that a ResourceTemplate's parameter schema includes parameter descriptions.""" templates = list(fastapi_server._resource_manager.get_templates().values()) - get_template = next( - (t for t in templates if "items__item_id__get" in t.name), None - ) + get_template = next((t for t in templates if "get_item_items" in t.name), None) assert get_template is not None, "GET /items/{item_id} template wasn't created" assert "properties" in get_template.parameters, ( @@ -1691,7 +1671,7 @@ class TestFastAPIDescriptionPropagation: async with Client(fastapi_server) as client: templates = await client.list_resource_templates() get_template = next( - (t for t in templates if "items__item_id__get" in t.name), None + (t for t in templates if "get_item_items" in t.name), None ) assert get_template is not None, ( @@ -1821,9 +1801,7 @@ class TestEnumHandling: tools = server._tool_manager.list_tools() # Find the read_item tool - read_item_tool = next( - (t for t in tools if t.name == "read_item_items__item_id__post"), None - ) + read_item_tool = next((t for t in tools if t.name == "read_item_items"), None) # Verify the tool exists assert read_item_tool is not None, "read_item tool wasn't created" diff --git a/tests/server/openapi/test_openapi_path_parameters.py b/tests/server/openapi/test_openapi_path_parameters.py index 517382a2c..1d188fb60 100644 --- a/tests/server/openapi/test_openapi_path_parameters.py +++ b/tests/server/openapi/test_openapi_path_parameters.py @@ -84,7 +84,7 @@ async def test_fastmcp_from_openapi(array_path_spec, mock_client): # Verify the tool was created using the MCP protocol method tools_result = await mcp.get_tools() tool_names = [tool.name for tool in tools_result.values()] - assert "test-operation" in tool_names + assert "test_operation" in tool_names async def test_array_path_parameter_handling(mock_client): @@ -93,7 +93,7 @@ async def test_array_path_parameter_handling(mock_client): route = HTTPRoute( path="/select/{days}", method="PUT", - operation_id="test-operation", + operation_id="test_operation", parameters=[ ParameterInfo( name="days", @@ -122,7 +122,7 @@ async def test_array_path_parameter_handling(mock_client): tool = OpenAPITool( client=mock_client, route=route, - name="test-operation", + name="test_operation", description="Test operation", parameters={}, ) @@ -163,7 +163,7 @@ async def test_integration_array_path_parameter(array_path_spec, mock_client): mcp = FastMCP.from_openapi(array_path_spec, client=mock_client) # Call the tool with a single value - await mcp._mcp_call_tool("test-operation", {"days": ["monday"]}) + await mcp._mcp_call_tool("test_operation", {"days": ["monday"]}) # Check the request was made correctly mock_client.request.assert_called_with( @@ -177,7 +177,7 @@ async def test_integration_array_path_parameter(array_path_spec, mock_client): mock_client.request.reset_mock() # Call the tool with multiple values - await mcp._mcp_call_tool("test-operation", {"days": ["monday", "tuesday"]}) + await mcp._mcp_call_tool("test_operation", {"days": ["monday", "tuesday"]}) # Check the request was made correctly mock_client.request.assert_called_with( From c9d82d8ab88534e091b67b3aa90811614ab2f864 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 23 May 2025 17:24:42 -0400 Subject: [PATCH 083/114] add name tests --- src/fastmcp/server/openapi.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py index 9fff30bce..69fc2a327 100644 --- a/src/fastmcp/server/openapi.py +++ b/src/fastmcp/server/openapi.py @@ -38,7 +38,10 @@ HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"] def _slugify(text: str) -> str: - """Convert text to a URL-friendly slug format.""" + """ + Convert text to a URL-friendly slug format that only contains lowercase + letters, uppercase letters, numbers, and underscores. + """ if not text: return "" @@ -807,7 +810,7 @@ class FastMCPOpenAPI(FastMCP): if route.operation_id in self._mcp_names: name = self._mcp_names[route.operation_id] else: - # If there's a double underscore, use the first part + # If there's a double underscore in the operationId, use the first part name = route.operation_id.split("__")[0] else: name = route.summary or f"{route.method}_{route.path}" From b924dd54d436315d2733d53f8bc7aae4527b3105 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 23 May 2025 17:31:46 -0400 Subject: [PATCH 084/114] Add tests --- tests/server/openapi/test_openapi.py | 284 +++++++++++++++++++++++++++ 1 file changed, 284 insertions(+) diff --git a/tests/server/openapi/test_openapi.py b/tests/server/openapi/test_openapi.py index 6eed469b5..f07f6c87c 100644 --- a/tests/server/openapi/test_openapi.py +++ b/tests/server/openapi/test_openapi.py @@ -2114,3 +2114,287 @@ class TestRouteMapTags: "getMetrics", } assert tool_names == expected_tools + + +class TestMCPNames: + """Tests for the mcp_names dictionary functionality.""" + + @pytest.fixture + def mcp_names_openapi_spec(self) -> dict: + """OpenAPI spec with various operationIds for testing naming strategies.""" + return { + "openapi": "3.1.0", + "info": {"title": "MCP Names Test API", "version": "1.0.0"}, + "paths": { + "/users": { + "get": { + "operationId": "list_users__with_pagination", + "summary": "Get All Users", + "responses": {"200": {"description": "Success"}}, + }, + "post": { + "operationId": "create_user_admin__special_permissions", + "summary": "Create New User", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + } + } + }, + }, + "responses": {"201": {"description": "Created"}}, + }, + }, + "/users/{id}": { + "get": { + "operationId": "get_user_by_id__admin_only", + "summary": "Fetch Single User Profile", + "parameters": [ + { + "name": "id", + "in": "path", + "required": True, + "schema": {"type": "integer"}, + } + ], + "responses": {"200": {"description": "Success"}}, + } + }, + "/very-long-endpoint-name": { + "get": { + "operationId": "this_is_a_very_long_operation_id_that_exceeds_fifty_six_characters_and_should_be_truncated", + "summary": "This Is A Very Long Summary That Should Also Be Truncated When Used As Name", + "responses": {"200": {"description": "Success"}}, + } + }, + "/special": { + "get": { + "operationId": "special-chars@and#spaces in$operation%id", + "summary": "Special Chars & Spaces In Summary!", + "responses": {"200": {"description": "Success"}}, + } + }, + }, + } + + @pytest.fixture + async def mock_client(self) -> httpx.AsyncClient: + """Mock client for testing.""" + + async def _responder(request): + return httpx.Response(200, json={"status": "ok"}) + + transport = httpx.MockTransport(_responder) + return httpx.AsyncClient(transport=transport, base_url="http://test") + + async def test_mcp_names_custom_mapping(self, mcp_names_openapi_spec, mock_client): + """Test that mcp_names dictionary provides custom names for components.""" + mcp_names = { + "list_users__with_pagination": "user_list", + "create_user_admin__special_permissions": "admin_create_user", + "get_user_by_id__admin_only": "user_detail", + } + + server = FastMCPOpenAPI( + openapi_spec=mcp_names_openapi_spec, + client=mock_client, + mcp_names=mcp_names, + ) + + # Check tools use custom names + tools = server._tool_manager.list_tools() + tool_names = {tool.name for tool in tools} + assert "admin_create_user" in tool_names + + # Check resource templates use custom names + templates = list(server._resource_manager.get_templates().values()) + template_names = {template.name for template in templates} + assert "user_detail" in template_names + + # Check resources use custom names + resources = list(server._resource_manager.get_resources().values()) + resource_names = {resource.name for resource in resources} + assert "user_list" in resource_names + + async def test_mcp_names_fallback_to_operation_id_short( + self, mcp_names_openapi_spec, mock_client + ): + """Test fallback to operationId up to double underscore when not in mcp_names.""" + # Only provide mapping for one operationId + mcp_names = { + "list_users__with_pagination": "custom_user_list", + } + + server = FastMCPOpenAPI( + openapi_spec=mcp_names_openapi_spec, + client=mock_client, + mcp_names=mcp_names, + ) + + tools = server._tool_manager.list_tools() + tool_names = {tool.name for tool in tools} + + templates = list(server._resource_manager.get_templates().values()) + template_names = {template.name for template in templates} + + resources = list(server._resource_manager.get_resources().values()) + resource_names = {resource.name for resource in resources} + + # Custom mapped name should be used + assert "custom_user_list" in resource_names + + # Unmapped operationIds should use short version (up to __) + assert "create_user_admin" in tool_names + assert "get_user_by_id" in template_names + + async def test_names_are_slugified(self, mcp_names_openapi_spec, mock_client): + """Test that names are properly slugified (spaces, special chars removed).""" + server = FastMCPOpenAPI( + openapi_spec=mcp_names_openapi_spec, + client=mock_client, + ) + + resources = list(server._resource_manager.get_resources().values()) + resource_names = { + resource.name for resource in resources if resource.name is not None + } + + # Special chars and spaces should be slugified + slugified_name = next( + (name for name in resource_names if "special" in name), None + ) + assert slugified_name is not None + # Should not contain special characters or spaces + assert "@" not in slugified_name + assert "#" not in slugified_name + assert "$" not in slugified_name + assert "%" not in slugified_name + assert " " not in slugified_name + + async def test_names_are_truncated_to_56_chars( + self, mcp_names_openapi_spec, mock_client + ): + """Test that names are truncated to 56 characters maximum.""" + server = FastMCPOpenAPI( + openapi_spec=mcp_names_openapi_spec, + client=mock_client, + ) + + # Check all component types + all_names = [] + + tools = server._tool_manager.list_tools() + all_names.extend(tool.name for tool in tools) + + resources = list(server._resource_manager.get_resources().values()) + all_names.extend(resource.name for resource in resources) + + templates = list(server._resource_manager.get_templates().values()) + all_names.extend(template.name for template in templates) + + # All names should be 56 characters or less + for name in all_names: + assert len(name) <= 56, ( + f"Name '{name}' exceeds 56 characters (length: {len(name)})" + ) + + # Verify that the long operationId was actually truncated + long_name = next((name for name in all_names if len(name) > 50), None) + assert long_name is not None, "Expected to find a truncated name for testing" + + async def test_mcp_names_with_from_openapi_classmethod( + self, mcp_names_openapi_spec, mock_client + ): + """Test mcp_names works with FastMCP.from_openapi() classmethod.""" + mcp_names = { + "list_users__with_pagination": "openapi_user_list", + } + + server = FastMCP.from_openapi( + openapi_spec=mcp_names_openapi_spec, + client=mock_client, + mcp_names=mcp_names, + ) + + resources = list(server._resource_manager.get_resources().values()) + resource_names = {resource.name for resource in resources} + assert "openapi_user_list" in resource_names + + async def test_mcp_names_with_from_fastapi_classmethod(self): + """Test mcp_names works with FastMCP.from_fastapi() classmethod.""" + from fastapi import FastAPI + from pydantic import BaseModel + + app = FastAPI(title="FastAPI MCP Names Test") + + class User(BaseModel): + name: str + + @app.get("/users", operation_id="list_users__with_filters") + async def get_users() -> list[User]: + return [User(name="test")] + + @app.post("/users", operation_id="create_user__admin_required") + async def create_user(user: User) -> User: + return user + + mcp_names = { + "list_users__with_filters": "fastapi_user_list", + "create_user__admin_required": "fastapi_create_user", + } + + server = FastMCP.from_fastapi( + app=app, + mcp_names=mcp_names, + ) + + tools = server._tool_manager.list_tools() + tool_names = {tool.name for tool in tools} + + resources = list(server._resource_manager.get_resources().values()) + resource_names = {resource.name for resource in resources} + + assert "fastapi_create_user" in tool_names + assert "fastapi_user_list" in resource_names + + async def test_mcp_names_custom_names_are_also_truncated( + self, mcp_names_openapi_spec, mock_client + ): + """Test that custom names in mcp_names are also truncated to 56 characters.""" + # Provide a custom name that's longer than 56 characters + very_long_custom_name = "this_is_a_very_long_custom_name_that_exceeds_fifty_six_characters_and_should_be_truncated" + + mcp_names = { + "list_users__with_pagination": very_long_custom_name, + } + + server = FastMCPOpenAPI( + openapi_spec=mcp_names_openapi_spec, + client=mock_client, + mcp_names=mcp_names, + ) + + resources = list(server._resource_manager.get_resources().values()) + resource_names = { + resource.name for resource in resources if resource.name is not None + } + + # Find the resource that should have the custom name + truncated_name = next( + ( + name + for name in resource_names + if "this_is_a_very_long_custom_name" in name + ), + None, + ) + assert truncated_name is not None + assert len(truncated_name) <= 56 + assert ( + len(truncated_name) == 56 + ) # Should be exactly 56 since original was longer From c7162c660b216792ddb07a34999b35c1adf111fe Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 23 May 2025 17:54:46 -0400 Subject: [PATCH 085/114] Update docs --- docs/servers/openapi.mdx | 46 +++++++++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/docs/servers/openapi.mdx b/docs/servers/openapi.mdx index cc59c950d..248c403ee 100644 --- a/docs/servers/openapi.mdx +++ b/docs/servers/openapi.mdx @@ -231,6 +231,38 @@ mcp = FastMCP.from_openapi( ## Customizing MCP Components + + +### Component Names + + + +FastMCP automatically generates names for MCP components based on the OpenAPI specification. By default, it uses the `operationId` from your OpenAPI spec, up to the first double underscore (`__`). + +All component names are automatically: +- **Slugified**: Spaces and special characters are converted to underscores or removed +- **Truncated**: Limited to 48 characters maximum to ensure compatibility +- **Unique**: If multiple components have the same name, a number is automatically appended to make them unique + +For more control over component names, you can provide an `mcp_names` dictionary that maps `operationId` values to your desired names. The `operationId` must be exactly as it appears in the OpenAPI spec. The provided name will always be slugified and truncated. + +```python {5-9} +from fastmcp import FastMCP + +mcp = FastMCP.from_openapi( + ... + mcp_names={ + "list_users__with_pagination": "user_list", + "create_user__admin_required": "create_user", + "get_user_details__admin_required": "user_detail", + } +) +``` + +Any `operationId` not found in `mcp_names` will use the default strategy (operationId up to the first `__`). + + +### Advanced Customization By default, FastMCP creates MCP components using a variety of metadata from the OpenAPI spec, such as incorporating the OpenAPI description into the MCP component description. @@ -271,7 +303,6 @@ mcp = FastMCP.from_openapi( mcp_component_fn=customize_components, ) ``` - ## Request Parameter Handling FastMCP intelligently handles different types of parameters in OpenAPI requests: @@ -376,15 +407,15 @@ from fastmcp import FastMCP # Your FastAPI app app = FastAPI(title="My API", version="1.0.0") -@app.get("/items", tags=["items"]) +@app.get("/items", tags=["items"], operation_id="list_items") def list_items(): return [{"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}] -@app.get("/items/{item_id}", tags=["items", "detail"]) +@app.get("/items/{item_id}", tags=["items", "detail"], operation_id="get_item") def get_item(item_id: int): return {"id": item_id, "name": f"Item {item_id}"} -@app.post("/items", tags=["items", "create"]) +@app.post("/items", tags=["items", "create"], operation_id="create_item") def create_item(name: str): return {"id": 3, "name": name} @@ -395,6 +426,8 @@ if __name__ == "__main__": mcp.run() # Run as MCP server ``` +Note that operation ids are optional, but are used to create component names. You can also provide custom names, just like with OpenAPI specs. + FastMCP servers are not FastAPI apps, even when created from one. To learn how to deploy them as an ASGI app, see the [ASGI Integration](/deployment/asgi) documentation. @@ -413,6 +446,7 @@ mcp = FastMCP.from_fastapi( app=app, name="My Custom Server", timeout=5.0, + mcp_names={"operationId": "friendly_name"}, # Custom component names route_maps=[ # Admin endpoints become tools RouteMap(methods="*", pattern=r"^/admin/.*", mcp_type=MCPType.TOOL), @@ -421,6 +455,9 @@ mcp = FastMCP.from_fastapi( ], route_map_fn=my_route_mapper, mcp_component_fn=my_component_customizer, + mcp_names={ + "get_user_details_users__user_id__get": "get_user_details", + } ) ``` @@ -430,4 +467,3 @@ mcp = FastMCP.from_fastapi( - **Schema inheritance**: Pydantic models and validation are preserved - **ASGI transport**: Direct in-memory communication (no HTTP overhead) - **Full FastAPI features**: Dependencies, middleware, authentication all work - From f57c6115be2400b4a9f5ff1862bc2121e564f356 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 23 May 2025 17:59:23 -0400 Subject: [PATCH 086/114] Update docs/servers/openapi.mdx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/servers/openapi.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/servers/openapi.mdx b/docs/servers/openapi.mdx index 248c403ee..6ef6a942d 100644 --- a/docs/servers/openapi.mdx +++ b/docs/servers/openapi.mdx @@ -241,7 +241,7 @@ FastMCP automatically generates names for MCP components based on the OpenAPI sp All component names are automatically: - **Slugified**: Spaces and special characters are converted to underscores or removed -- **Truncated**: Limited to 48 characters maximum to ensure compatibility +- **Truncated**: Limited to 56 characters maximum to ensure compatibility - **Unique**: If multiple components have the same name, a number is automatically appended to make them unique For more control over component names, you can provide an `mcp_names` dictionary that maps `operationId` values to your desired names. The `operationId` must be exactly as it appears in the OpenAPI spec. The provided name will always be slugified and truncated. From 010170337f274ed3bf44a806a2a306de8c487405 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 23 May 2025 18:53:55 -0400 Subject: [PATCH 087/114] Ensure that tools/templates/prompts are compatible with callable objects --- pyproject.toml | 6 ++ src/fastmcp/prompts/prompt.py | 10 +++- src/fastmcp/resources/template.py | 15 +++-- src/fastmcp/settings.py | 14 ++++- src/fastmcp/tools/tool.py | 6 +- src/fastmcp/utilities/logging.py | 7 ++- tests/prompts/test_prompt.py | 24 ++++++++ tests/prompts/test_prompt_manager.py | 44 ++++++++++++++ tests/resources/test_resource_template.py | 25 ++++++++ tests/server/test_server_interactions.py | 46 +++++++++++++++ tests/tools/test_tool.py | 31 ++++++++++ tests/tools/test_tool_manager.py | 72 +++++++++++++++++++++++ uv.lock | 19 +++++- 13 files changed, 307 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a4c59a58d..ac8735405 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ dev = [ "pytest>=8.3.3", "pytest-asyncio>=0.23.5", "pytest-cov>=6.1.1", + "pytest-env>=1.1.5", "pytest-flakefinder", "pytest-report>=0.2.1", "pytest-timeout>=2.4.0", @@ -84,6 +85,11 @@ asyncio_default_fixture_loop_scope = "session" asyncio_default_test_loop_scope = "session" filterwarnings = [] timeout = 3 +env = [ + "FASTMCP_TEST_MODE=1", + 'D:FASTMCP_LOG_LEVEL=DEBUG', + 'D:FASTMCP_ENABLE_RICH_TRACEBACKS=0', +] [tool.pyright] include = ["src", "tests"] diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py index 26698f5d6..0b870438e 100644 --- a/src/fastmcp/prompts/prompt.py +++ b/src/fastmcp/prompts/prompt.py @@ -97,7 +97,7 @@ class Prompt(BaseModel): """ from fastmcp.server.context import Context - func_name = name or fn.__name__ + func_name = name or getattr(fn, "__name__", None) or fn.__class__.__name__ if func_name == "": raise ValueError("You must provide a name for lambda functions") @@ -109,6 +109,12 @@ class Prompt(BaseModel): if param.kind == inspect.Parameter.VAR_KEYWORD: raise ValueError("Functions with **kwargs are not supported as prompts") + description = description or fn.__doc__ + + # if the fn is a callable class, we need to get the __call__ method from here out + if not inspect.isfunction(fn): + fn = fn.__call__ + type_adapter = get_cached_typeadapter(fn) parameters = type_adapter.json_schema() @@ -139,7 +145,7 @@ class Prompt(BaseModel): return cls( name=func_name, - description=description or fn.__doc__, + description=description, arguments=arguments, fn=fn, tags=tags or set(), diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index eaf0cfe7b..9e7984a67 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -14,7 +14,6 @@ from pydantic import ( BaseModel, BeforeValidator, Field, - TypeAdapter, field_validator, validate_call, ) @@ -25,6 +24,7 @@ from fastmcp.utilities.json_schema import compress_schema from fastmcp.utilities.types import ( _convert_set_defaults, find_kwarg_by_type, + get_cached_typeadapter, ) @@ -97,7 +97,7 @@ class ResourceTemplate(BaseModel): """Create a template from a function.""" from fastmcp.server.context import Context - func_name = name or fn.__name__ + func_name = name or getattr(fn, "__name__", None) or fn.__class__.__name__ if func_name == "": raise ValueError("You must provide a name for lambda functions") @@ -148,8 +148,13 @@ class ResourceTemplate(BaseModel): f"URI parameters {uri_params} must be a subset of the function arguments: {func_params}" ) - # Get schema from TypeAdapter - will fail if function isn't properly typed - parameters = TypeAdapter(fn).json_schema() + description = description or fn.__doc__ or "" + + if not inspect.isfunction(fn): + fn = fn.__call__ + + type_adapter = get_cached_typeadapter(fn) + parameters = type_adapter.json_schema() # compress the schema prune_params = [context_kwarg] if context_kwarg else None @@ -161,7 +166,7 @@ class ResourceTemplate(BaseModel): return cls( uri_template=uri_template, name=func_name, - description=description or fn.__doc__ or "", + description=description, mime_type=mime_type or "text/plain", fn=fn, parameters=parameters, diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index c1dbf5447..662b0db6f 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -29,6 +29,16 @@ class Settings(BaseSettings): test_mode: bool = False log_level: LOG_LEVEL = "INFO" + enable_rich_tracebacks: Annotated[ + bool, + Field( + description=inspect.cleandoc( + """ + If True, will use rich tracebacks for logging. + """ + ) + ), + ] = True client_raise_first_exceptiongroup_error: Annotated[ bool, @@ -82,7 +92,9 @@ class Settings(BaseSettings): """Finalize the settings.""" from fastmcp.utilities.logging import configure_logging - configure_logging(self.log_level) + configure_logging( + self.log_level, enable_rich_tracebacks=self.enable_rich_tracebacks + ) return self diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index 73b84d76f..4f22ca4f6 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -69,13 +69,17 @@ class Tool(BaseModel): if param.kind == inspect.Parameter.VAR_KEYWORD: raise ValueError("Functions with **kwargs are not supported as tools") - func_name = name or fn.__name__ + func_name = name or getattr(fn, "__name__", None) or fn.__class__.__name__ if func_name == "": raise ValueError("You must provide a name for lambda functions") func_doc = description or fn.__doc__ or "" + # if the fn is a callable class, we need to get the __call__ method from here out + if not inspect.isfunction(fn): + fn = fn.__call__ + type_adapter = get_cached_typeadapter(fn) schema = type_adapter.json_schema() diff --git a/src/fastmcp/utilities/logging.py b/src/fastmcp/utilities/logging.py index d30074190..d30eba0dc 100644 --- a/src/fastmcp/utilities/logging.py +++ b/src/fastmcp/utilities/logging.py @@ -22,6 +22,7 @@ def get_logger(name: str) -> logging.Logger: def configure_logging( level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | int = "INFO", logger: logging.Logger | None = None, + enable_rich_tracebacks: bool = True, ) -> None: """ Configure logging for FastMCP. @@ -30,11 +31,15 @@ def configure_logging( logger: the logger to configure level: the log level to use """ + if logger is None: logger = logging.getLogger("FastMCP") # Only configure the FastMCP logger namespace - handler = RichHandler(console=Console(stderr=True), rich_tracebacks=True) + handler = RichHandler( + console=Console(stderr=True), + rich_tracebacks=enable_rich_tracebacks, + ) formatter = logging.Formatter("%(message)s") handler.setFormatter(formatter) diff --git a/tests/prompts/test_prompt.py b/tests/prompts/test_prompt.py index c77ea0b88..a0cda7b2d 100644 --- a/tests/prompts/test_prompt.py +++ b/tests/prompts/test_prompt.py @@ -47,6 +47,30 @@ class TestRenderPrompt: ) ] + async def test_callable_object(self): + class MyPrompt: + def __call__(self, name: str) -> str: + return f"Hello, {name}!" + + prompt = Prompt.from_function(MyPrompt()) + assert await prompt.render(arguments=dict(name="World")) == [ + PromptMessage( + role="user", content=TextContent(type="text", text="Hello, World!") + ) + ] + + async def test_async_callable_object(self): + class MyPrompt: + async def __call__(self, name: str) -> str: + return f"Hello, {name}!" + + prompt = Prompt.from_function(MyPrompt()) + assert await prompt.render(arguments=dict(name="World")) == [ + PromptMessage( + role="user", content=TextContent(type="text", text="Hello, World!") + ) + ] + 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." diff --git a/tests/prompts/test_prompt_manager.py b/tests/prompts/test_prompt_manager.py index d44dde0bf..e00aba3e0 100644 --- a/tests/prompts/test_prompt_manager.py +++ b/tests/prompts/test_prompt_manager.py @@ -141,6 +141,8 @@ class TestPromptManager: assert prompts["fn1"] == prompt1 assert prompts["fn2"] == prompt2 + +class TestRenderPrompt: async def test_render_prompt(self): """Test rendering a prompt.""" @@ -177,6 +179,48 @@ class TestPromptManager: ) ] + async def test_render_prompt_callable_object(self): + """Test rendering a prompt with a callable object.""" + + class MyPrompt: + """A callable object that can be used as a prompt.""" + + def __call__(self, name: str) -> str: + """ignore this""" + return f"Hello, {name}!" + + manager = PromptManager() + prompt = Prompt.from_function(MyPrompt()) + manager.add_prompt(prompt) + result = await manager.render_prompt("MyPrompt", arguments={"name": "World"}) + assert result.description == "A callable object that can be used as a prompt." + assert result.messages == [ + PromptMessage( + role="user", content=TextContent(type="text", text="Hello, World!") + ) + ] + + async def test_render_prompt_callable_object_async(self): + """Test rendering a prompt with a callable object.""" + + class MyPrompt: + """A callable object that can be used as a prompt.""" + + async def __call__(self, name: str) -> str: + """ignore this""" + return f"Hello, {name}!" + + manager = PromptManager() + prompt = Prompt.from_function(MyPrompt()) + manager.add_prompt(prompt) + result = await manager.render_prompt("MyPrompt", arguments={"name": "World"}) + assert result.description == "A callable object that can be used as a prompt." + assert result.messages == [ + PromptMessage( + role="user", content=TextContent(type="text", text="Hello, World!") + ) + ] + async def test_render_unknown_prompt(self): """Test rendering a non-existent prompt.""" manager = PromptManager() diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py index 089b4ab89..5bb3846a7 100644 --- a/tests/resources/test_resource_template.py +++ b/tests/resources/test_resource_template.py @@ -368,6 +368,31 @@ class TestResourceTemplate: ) assert template.uri_template == "test://{x}/{y}/{z}" + async def test_callable_object_as_template(self): + """Test that a callable object can be used as a template.""" + + class MyTemplate: + """This is my template""" + + def __call__(self, x: str) -> str: + """ignore this""" + return f"X was {x}" + + template = ResourceTemplate.from_function( + fn=MyTemplate(), + uri_template="test://{x}", + name="test", + ) + + resource = await template.create_resource( + "test://foo", + {"x": "foo"}, + ) + + assert isinstance(resource, FunctionResource) + content = await resource.read() + assert content == "X was foo" + class TestMatchUriTemplate: """Test match_uri_template function.""" diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py index 2e739c2b8..d7c7577c5 100644 --- a/tests/server/test_server_interactions.py +++ b/tests/server/test_server_interactions.py @@ -709,6 +709,21 @@ class TestToolContextInjection: assert len(tools) == 1 # Note: MCPTool from the client API doesn't expose tags + async def test_callable_object_with_context(self): + """Test that a callable object can be used as a tool with context.""" + mcp = FastMCP() + + class MyTool: + async def __call__(self, x: int, ctx: Context) -> int: + return x + int(ctx.request_id) + + mcp.add_tool(MyTool()) + + async with Client(mcp) as client: + result = await client.call_tool("MyTool", {"x": 2}) + assert isinstance(result[0], TextContent) + assert result[0].text == "4" + class TestResource: async def test_text_resource(self): @@ -1096,6 +1111,20 @@ class TestResourceTemplateContext: assert isinstance(result[0], TextResourceContents) assert result[0].text.startswith("Resource template: test 2") + async def test_resource_template_context_with_callable_object(self): + mcp = FastMCP() + + class MyResource: + def __call__(self, param: str, ctx: Context) -> str: + return f"Resource template: {param} {ctx.request_id}" + + mcp.add_resource_fn(MyResource(), uri="resource://{param}") + + async with Client(mcp) as client: + result = await client.read_resource(AnyUrl("resource://test")) + assert isinstance(result[0], TextResourceContents) + assert result[0].text.startswith("Resource template: test 2") + class TestPrompts: """Test prompt functionality in FastMCP server.""" @@ -1298,3 +1327,20 @@ class TestPromptContext: assert len(result.messages) == 1 message = result.messages[0] assert message.role == "user" + + async def test_prompt_context_with_callable_object(self): + mcp = FastMCP() + + class MyPrompt: + def __call__(self, name: str, ctx: Context) -> str: + return f"Hello, {name}! {ctx.request_id}" + + mcp.add_prompt(MyPrompt(), name="my_prompt") + + async with Client(mcp) as client: + result = await client.get_prompt("my_prompt", {"name": "World"}) + assert len(result.messages) == 1 + message = result.messages[0] + assert message.role == "user" + assert isinstance(message.content, TextContent) + assert message.content.text == "Hello, World! 2" diff --git a/tests/tools/test_tool.py b/tests/tools/test_tool.py index 89f1c4822..141035534 100644 --- a/tests/tools/test_tool.py +++ b/tests/tools/test_tool.py @@ -21,6 +21,7 @@ class TestToolFromFunction: assert tool.name == "add" assert tool.description == "Add two numbers." + assert len(tool.parameters["properties"]) == 2 assert tool.parameters["properties"]["a"]["type"] == "integer" assert tool.parameters["properties"]["b"]["type"] == "integer" @@ -37,6 +38,36 @@ class TestToolFromFunction: assert tool.description == "Fetch data from URL." assert tool.parameters["properties"]["url"]["type"] == "string" + def test_callable_object(self): + class Adder: + """Adds two numbers.""" + + def __call__(self, x: int, y: int) -> int: + """ignore this""" + return x + y + + tool = Tool.from_function(Adder()) + assert tool.name == "Adder" + assert tool.description == "Adds two numbers." + assert len(tool.parameters["properties"]) == 2 + assert tool.parameters["properties"]["x"]["type"] == "integer" + assert tool.parameters["properties"]["y"]["type"] == "integer" + + def test_async_callable_object(self): + class Adder: + """Adds two numbers.""" + + async def __call__(self, x: int, y: int) -> int: + """ignore this""" + return x + y + + tool = Tool.from_function(Adder()) + assert tool.name == "Adder" + assert tool.description == "Adds two numbers." + assert len(tool.parameters["properties"]) == 2 + assert tool.parameters["properties"]["x"]["type"] == "integer" + assert tool.parameters["properties"]["y"]["type"] == "integer" + def test_pydantic_model_function(self): """Test registering a function that takes a Pydantic model.""" diff --git a/tests/tools/test_tool_manager.py b/tests/tools/test_tool_manager.py index 8a4d6c556..5cc3aa33e 100644 --- a/tests/tools/test_tool_manager.py +++ b/tests/tools/test_tool_manager.py @@ -71,6 +71,44 @@ class TestAddTools: assert "age" in tool.parameters["$defs"]["UserInput"]["properties"] assert "flag" in tool.parameters["properties"] + def test_callable_object(self): + class Adder: + """Adds two numbers.""" + + def __call__(self, x: int, y: int) -> int: + """ignore this""" + return x + y + + manager = ToolManager() + manager.add_tool_from_fn(Adder()) + + tool = manager.get_tool("Adder") + assert tool is not None + assert tool.name == "Adder" + assert tool.description == "Adds two numbers." + assert len(tool.parameters["properties"]) == 2 + assert tool.parameters["properties"]["x"]["type"] == "integer" + assert tool.parameters["properties"]["y"]["type"] == "integer" + + def test_async_callable_object(self): + class Adder: + """Adds two numbers.""" + + async def __call__(self, x: int, y: int) -> int: + """ignore this""" + return x + y + + manager = ToolManager() + manager.add_tool_from_fn(Adder()) + + tool = manager.get_tool("Adder") + assert tool is not None + assert tool.name == "Adder" + assert tool.description == "Adds two numbers." + assert len(tool.parameters["properties"]) == 2 + assert tool.parameters["properties"]["x"]["type"] == "integer" + assert tool.parameters["properties"]["y"]["type"] == "integer" + async def test_tool_with_image_return(self): def image_tool(data: bytes) -> Image: return Image(data=data) @@ -303,6 +341,40 @@ class TestCallTools: assert result[0].text == "10" assert json.loads(result[0].text) == 10 + async def test_call_tool_callable_object(self): + class Adder: + """Adds two numbers.""" + + def __call__(self, x: int, y: int) -> int: + """ignore this""" + return x + y + + manager = ToolManager() + manager.add_tool_from_fn(Adder()) + result = await manager.call_tool("Adder", {"x": 1, "y": 2}) + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].text == "3" + assert json.loads(result[0].text) == 3 + + async def test_call_tool_callable_object_async(self): + class Adder: + """Adds two numbers.""" + + async def __call__(self, x: int, y: int) -> int: + """ignore this""" + return x + y + + manager = ToolManager() + manager.add_tool_from_fn(Adder()) + result = await manager.call_tool("Adder", {"x": 1, "y": 2}) + assert isinstance(result, list) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + assert result[0].text == "3" + assert json.loads(result[0].text) == 3 + async def test_call_tool_with_default_args(self): def add(a: int, b: int = 1) -> int: """Add two numbers.""" diff --git a/uv.lock b/uv.lock index 8613b7367..df4d56995 100644 --- a/uv.lock +++ b/uv.lock @@ -329,6 +329,7 @@ dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "pytest-env" }, { name = "pytest-flakefinder" }, { name = "pytest-report" }, { name = "pytest-timeout" }, @@ -360,6 +361,7 @@ dev = [ { name = "pytest", specifier = ">=8.3.3" }, { name = "pytest-asyncio", specifier = ">=0.23.5" }, { name = "pytest-cov", specifier = ">=6.1.1" }, + { name = "pytest-env", specifier = ">=1.1.5" }, { name = "pytest-flakefinder" }, { name = "pytest-report", specifier = ">=0.2.1" }, { name = "pytest-timeout", specifier = ">=2.4.0" }, @@ -586,9 +588,9 @@ dependencies = [ { name = "starlette" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bc/8d/0f4468582e9e97b0a24604b585c651dfd2144300ecffd1c06a680f5c8861/mcp-1.9.0.tar.gz", hash = "sha256:905d8d208baf7e3e71d70c82803b89112e321581bcd2530f9de0fe4103d28749", size = 281432 } +sdist = { url = "https://files.pythonhosted.org/packages/bc/8d/0f4468582e9e97b0a24604b585c651dfd2144300ecffd1c06a680f5c8861/mcp-1.9.0.tar.gz", hash = "sha256:905d8d208baf7e3e71d70c82803b89112e321581bcd2530f9de0fe4103d28749", size = 281432, upload-time = "2025-05-15T18:51:06.615Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/d5/22e36c95c83c80eb47c83f231095419cf57cf5cca5416f1c960032074c78/mcp-1.9.0-py3-none-any.whl", hash = "sha256:9dfb89c8c56f742da10a5910a1f64b0d2ac2c3ed2bd572ddb1cfab7f35957178", size = 125082 }, + { url = "https://files.pythonhosted.org/packages/a5/d5/22e36c95c83c80eb47c83f231095419cf57cf5cca5416f1c960032074c78/mcp-1.9.0-py3-none-any.whl", hash = "sha256:9dfb89c8c56f742da10a5910a1f64b0d2ac2c3ed2bd572ddb1cfab7f35957178", size = 125082, upload-time = "2025-05-15T18:51:04.916Z" }, ] [[package]] @@ -941,6 +943,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841, upload-time = "2025-04-05T14:07:49.641Z" }, ] +[[package]] +name = "pytest-env" +version = "1.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/31/27f28431a16b83cab7a636dce59cf397517807d247caa38ee67d65e71ef8/pytest_env-1.1.5.tar.gz", hash = "sha256:91209840aa0e43385073ac464a554ad2947cc2fd663a9debf88d03b01e0cc1cf", size = 8911, upload-time = "2024-09-17T22:39:18.566Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/b8/87cfb16045c9d4092cfcf526135d73b88101aac83bc1adcf82dfb5fd3833/pytest_env-1.1.5-py3-none-any.whl", hash = "sha256:ce90cf8772878515c24b31cd97c7fa1f4481cd68d588419fd45f10ecaee6bc30", size = 6141, upload-time = "2024-09-17T22:39:16.942Z" }, +] + [[package]] name = "pytest-flakefinder" version = "1.1.0" From 794ef3f11403ff9fe916da4a848ee1c70bd310bd Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 23 May 2025 19:00:54 -0400 Subject: [PATCH 088/114] Match functions and methods --- src/fastmcp/prompts/prompt.py | 2 +- src/fastmcp/resources/template.py | 2 +- src/fastmcp/tools/tool.py | 2 +- tests/utilities/test_tests.py | 7 ++++--- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py index 0b870438e..d9bae2b9a 100644 --- a/src/fastmcp/prompts/prompt.py +++ b/src/fastmcp/prompts/prompt.py @@ -112,7 +112,7 @@ class Prompt(BaseModel): description = description or fn.__doc__ # if the fn is a callable class, we need to get the __call__ method from here out - if not inspect.isfunction(fn): + if not inspect.isroutine(fn): fn = fn.__call__ type_adapter = get_cached_typeadapter(fn) diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index 9e7984a67..b7adb9481 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -150,7 +150,7 @@ class ResourceTemplate(BaseModel): description = description or fn.__doc__ or "" - if not inspect.isfunction(fn): + if not inspect.isroutine(fn): fn = fn.__call__ type_adapter = get_cached_typeadapter(fn) diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index 4f22ca4f6..e92eda26b 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -77,7 +77,7 @@ class Tool(BaseModel): func_doc = description or fn.__doc__ or "" # if the fn is a callable class, we need to get the __call__ method from here out - if not inspect.isfunction(fn): + if not inspect.isroutine(fn): fn = fn.__call__ type_adapter = get_cached_typeadapter(fn) diff --git a/tests/utilities/test_tests.py b/tests/utilities/test_tests.py index 9d4ea8822..104aaf9e6 100644 --- a/tests/utilities/test_tests.py +++ b/tests/utilities/test_tests.py @@ -4,6 +4,7 @@ from fastmcp.utilities.tests import temporary_settings class TestTemporarySettings: def test_temporary_settings(self): - with temporary_settings(log_level="DEBUG"): - assert fastmcp.settings.settings.log_level == "DEBUG" - assert fastmcp.settings.settings.log_level == "INFO" + assert fastmcp.settings.settings.log_level == "DEBUG" + with temporary_settings(log_level="ERROR"): + assert fastmcp.settings.settings.log_level == "ERROR" + assert fastmcp.settings.settings.log_level == "DEBUG" From c5165e159b2b700796658c7b5ad3ab99699f2b95 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 23 May 2025 21:37:28 -0400 Subject: [PATCH 089/114] Update openapi.mdx --- docs/servers/openapi.mdx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/docs/servers/openapi.mdx b/docs/servers/openapi.mdx index 6ef6a942d..580502a49 100644 --- a/docs/servers/openapi.mdx +++ b/docs/servers/openapi.mdx @@ -51,13 +51,7 @@ FastMCP analyzes your API specification and automatically creates MCP components | `GET` without path params | `GET /stats` | **Resource** | | `POST`, `PUT`, `PATCH`, `DELETE`, etc. | `POST /users` | **Tool** | - - -### Custom Route Maps - - - -FastMCP uses an ordered list of `RouteMap` objects to determine how to map OpenAPI routes to various MCP component types. +Interally, FastMCP uses an ordered list of `RouteMap` objects to determine how to map OpenAPI routes to various MCP component types. Each `RouteMap` specifies a combination of methods, patterns, and tags, as well as a corresponding MCP component type. Each OpenAPI route is checked against each `RouteMap` in order, and the first one that matches every criteria is used to determine its converted MCP type. A special type, `EXCLUDE`, can be used to exclude routes from the MCP server entirely. @@ -66,7 +60,7 @@ Each `RouteMap` specifies a combination of methods, patterns, and tags, as well - **Tags**: A set of OpenAPI tags that must all be present. An empty set (`{}`) means no tag filtering, so the route matches regardless of its tags. - **MCP type**: What MCP component type to create (`TOOL`, `RESOURCE`, `RESOURCE_TEMPLATE`, or `EXCLUDE`) -To illustrate this in practice, here are FastMCP's default route mappings as a list of `RouteMap` objects: +To illustrate this in practice, here are FastMCP's default rules as a list of `RouteMap` objects: ```python from fastmcp.server.openapi import RouteMap, MCPType From 307a3b908a570c852242139601f150c60d11a724 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 24 May 2025 07:38:34 -0400 Subject: [PATCH 090/114] Ensure content-length is always stripped from client headers --- docs/patterns/http-requests.mdx | 42 ++++++++++++++++++------------ src/fastmcp/client/transports.py | 39 ++++----------------------- src/fastmcp/server/dependencies.py | 27 +++++++++++++++++++ src/fastmcp/server/openapi.py | 25 +++--------------- tests/client/test_openapi.py | 1 - 5 files changed, 60 insertions(+), 74 deletions(-) diff --git a/docs/patterns/http-requests.mdx b/docs/patterns/http-requests.mdx index 59356acd7..dd39deabc 100644 --- a/docs/patterns/http-requests.mdx +++ b/docs/patterns/http-requests.mdx @@ -23,7 +23,7 @@ from fastmcp import FastMCP from fastmcp.server.dependencies import get_http_request from starlette.requests import Request -mcp = FastMCP(name="HTTPRequestDemo") +mcp = FastMCP(name="HTTP Request Demo") @mcp.tool() async def user_agent_info() -> dict: @@ -48,32 +48,40 @@ This approach works anywhere within a request's execution flow, not just within 2. You're calling nested functions that need HTTP request data 3. You're working with middleware or other request processing code -## Important Notes +## Accessing HTTP Headers Only -- HTTP requests are only available when FastMCP is running as part of a web application -- Accessing the HTTP request outside of a web request context will raise a `RuntimeError` -- The `get_http_request()` function returns a standard [Starlette Request](https://www.starlette.io/requests/) object +If you only need request headers and want to avoid potential errors, you can use the `get_http_headers()` helper: -## Common Use Cases +```python {2} +from fastmcp import FastMCP +from fastmcp.server.dependencies import get_http_headers -### Accessing Request Headers - -```python -from fastmcp.server.dependencies import get_http_request +mcp = FastMCP(name="Headers Demo") @mcp.tool() -async def get_auth_info() -> dict: - """Get authentication information from request headers.""" - request = get_http_request() +async def safe_header_info() -> dict: + """Safely get header information without raising errors.""" + # Get headers (returns empty dict if no request context) + headers = get_http_headers() # Get authorization header - auth_header = request.headers.get("authorization", "") - - # Check for Bearer token + auth_header = headers.get("authorization", "") is_bearer = auth_header.startswith("Bearer ") return { + "user_agent": headers.get("user-agent", "Unknown"), + "content_type": headers.get("content-type", "Unknown"), "has_auth": bool(auth_header), - "auth_type": "Bearer" if is_bearer else "Other" if auth_header else "None" + "auth_type": "Bearer" if is_bearer else "Other" if auth_header else "None", + "headers_count": len(headers) } ``` + +By default, `get_http_headers()` excludes problematic headers like `content-length`. To include all headers, use `get_http_headers(include_all=True)`. + +## Important Notes + +- HTTP requests are only available when FastMCP is running as part of a web application +- Accessing the HTTP request with `get_http_request()` outside of a web request context will raise a `RuntimeError` +- The `get_http_headers()` function **never raises errors** - it returns an empty dict when no request context is available +- The `get_http_request()` function returns a standard [Starlette Request](https://www.starlette.io/requests/) object \ No newline at end of file diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 398c080fc..e43ab5760 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -25,7 +25,7 @@ from pydantic import AnyUrl from typing_extensions import Unpack from fastmcp.server import FastMCP as FastMCPServer -from fastmcp.server.dependencies import get_http_request +from fastmcp.server.dependencies import get_http_headers from fastmcp.server.server import FastMCP from fastmcp.utilities.logging import get_logger from fastmcp.utilities.mcp_config import MCPConfig, infer_transport_type_from_url @@ -35,11 +35,6 @@ if TYPE_CHECKING: logger = get_logger(__name__) -# these headers, when forwarded to the remote server, can cause issues -EXCLUDE_HEADERS = { - "content-length", -} - class SessionKwargs(TypedDict, total=False): """Keyword arguments for the MCP ClientSession constructor.""" @@ -138,23 +133,12 @@ class SSETransport(ClientTransport): async def connect_session( self, **session_kwargs: Unpack[SessionKwargs] ) -> AsyncIterator[ClientSession]: - client_kwargs: dict[str, Any] = { - "headers": self.headers, - } + client_kwargs: dict[str, Any] = {} # load headers from an active HTTP request, if available. This will only be true # if the client is used in a FastMCP Proxy, in which case the MCP client headers # need to be forwarded to the remote server. - try: - active_request = get_http_request() - for name, value in active_request.headers.items(): - name = name.lower() - if name not in self.headers and name not in { - h.lower() for h in EXCLUDE_HEADERS - }: - client_kwargs["headers"][name] = str(value) - except RuntimeError: - client_kwargs["headers"] = self.headers + client_kwargs["headers"] = get_http_headers() | self.headers # sse_read_timeout has a default value set, so we can't pass None without overriding it # instead we simply leave the kwarg out if it's not provided @@ -201,25 +185,12 @@ class StreamableHttpTransport(ClientTransport): async def connect_session( self, **session_kwargs: Unpack[SessionKwargs] ) -> AsyncIterator[ClientSession]: - client_kwargs: dict[str, Any] = { - "headers": self.headers, - } + client_kwargs: dict[str, Any] = {} # load headers from an active HTTP request, if available. This will only be true # if the client is used in a FastMCP Proxy, in which case the MCP client headers # need to be forwarded to the remote server. - try: - active_request = get_http_request() - for name, value in active_request.headers.items(): - name = name.lower() - if name not in self.headers and name not in { - h.lower() for h in EXCLUDE_HEADERS - }: - client_kwargs["headers"][name] = str(value) - - except RuntimeError: - client_kwargs["headers"] = self.headers - print(client_kwargs) + client_kwargs["headers"] = get_http_headers() | self.headers # sse_read_timeout has a default value set, so we can't pass None without overriding it # instead we simply leave the kwarg out if it's not provided diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py index a06560be1..1db620109 100644 --- a/src/fastmcp/server/dependencies.py +++ b/src/fastmcp/server/dependencies.py @@ -33,3 +33,30 @@ def get_http_request() -> Request: if request is None: raise RuntimeError("No active HTTP request found.") return request + + +def get_http_headers(include_all: bool = False) -> dict[str, str]: + """ + Extract headers from the current HTTP request if available. + + Never raises an exception, even if there is no active HTTP request (in which case + an empty dict is returned). + + By default, strips problematic headers like `content-length` that cause issues if forwarded to downstream clients. + If `include_all` is True, all headers are returned. + """ + if include_all: + exclude_headers = set() + else: + exclude_headers = {"content-length"} + + try: + request = get_http_request() + headers = { + name.lower(): str(value) + for name, value in request.headers.items() + if name not in exclude_headers + } + return headers + except RuntimeError: + return {} diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py index 69fc2a327..70226278e 100644 --- a/src/fastmcp/server/openapi.py +++ b/src/fastmcp/server/openapi.py @@ -18,7 +18,7 @@ from pydantic.networks import AnyUrl from fastmcp.exceptions import ToolError from fastmcp.resources import Resource, ResourceTemplate -from fastmcp.server.dependencies import get_http_request +from fastmcp.server.dependencies import get_http_headers from fastmcp.server.server import FastMCP from fastmcp.tools.tool import Tool, _convert_to_content from fastmcp.utilities import openapi @@ -60,25 +60,6 @@ def _slugify(text: str) -> str: return slug -def _get_mcp_client_headers() -> dict[str, str]: - """ - Extract headers from the current MCP client HTTP request if available. - - These headers will take precedence over OpenAPI-defined headers when both are present. - - Returns: - Dictionary of header name-value pairs (lowercased names), or empty dict if no HTTP request is active. - """ - try: - http_request = get_http_request() - return { - name.lower(): str(value) for name, value in http_request.headers.items() - } - except RuntimeError: - # No active HTTP request (e.g., STDIO transport), return empty dict - return {} - - # Type definitions for the mapping functions RouteMapFn = Callable[[HTTPRoute, "MCPType"], "MCPType | None"] ComponentFn = Callable[ @@ -423,7 +404,7 @@ class OpenAPITool(Tool): headers.update(openapi_headers) # Add headers from the current MCP client HTTP request (these take precedence) - mcp_headers = _get_mcp_client_headers() + mcp_headers = get_http_headers() headers.update(mcp_headers) # Prepare request body @@ -574,7 +555,7 @@ class OpenAPIResource(Resource): # Prepare headers from MCP client request if available headers = {} - mcp_headers = _get_mcp_client_headers() + mcp_headers = get_http_headers() headers.update(mcp_headers) response = await self._client.request( diff --git a/tests/client/test_openapi.py b/tests/client/test_openapi.py index fe4281007..be2334cf7 100644 --- a/tests/client/test_openapi.py +++ b/tests/client/test_openapi.py @@ -185,7 +185,6 @@ class TestClientHeaders: Test that client headers are passed through the proxy to the remove server. """ async with Client(transport=StreamableHttpTransport(proxy_server)) as client: - await client.ping() result = await client.read_resource("resource://get_headers_headers_get") assert isinstance(result[0], TextResourceContents) headers = json.loads(result[0].text) From 3475d1bef8617535583b0466de0bbd2e457b20fe Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 24 May 2025 07:43:52 -0400 Subject: [PATCH 091/114] Update dependencies.py --- src/fastmcp/server/dependencies.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py index 1db620109..8448a283d 100644 --- a/src/fastmcp/server/dependencies.py +++ b/src/fastmcp/server/dependencies.py @@ -50,13 +50,18 @@ def get_http_headers(include_all: bool = False) -> dict[str, str]: else: exclude_headers = {"content-length"} + # ensure all lowercase! + # (just in case) + exclude_headers = {h.lower() for h in exclude_headers} + + headers = {} + try: request = get_http_request() - headers = { - name.lower(): str(value) - for name, value in request.headers.items() - if name not in exclude_headers - } + for name, value in request.headers.items(): + lower_name = name.lower() + if lower_name not in exclude_headers: + headers[lower_name] = str(value) return headers except RuntimeError: return {} From 5cf5875581c6f2fadfe60266c17ea152b201c676 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 25 May 2025 13:57:21 -0400 Subject: [PATCH 092/114] Add notes about uv and claude desktop --- docs/deployment/cli.mdx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/deployment/cli.mdx b/docs/deployment/cli.mdx index 832a82a7e..2027c13bc 100644 --- a/docs/deployment/cli.mdx +++ b/docs/deployment/cli.mdx @@ -148,9 +148,12 @@ Install a MCP server in the Claude desktop app. fastmcp install server.py ``` - -This command installs your server in an isolated environment. All dependencies must be explicitly specified using the `--with` and/or `--with-editable` options. - + +Note that for security reasons, Claude runs every MCP server in a completely isolated environment. Therefore, all dependencies must be explicitly specified using the `--with` and/or `--with-editable` options (following `uv` conventions) or by attaching them to your server in code via the `dependencies` parameter. + +- **`uv` must be installed and available in your system PATH**. Claude Desktop runs in its own isolated environment and needs `uv` to manage dependencies. +- **On macOS, it is recommended to install `uv` globally with Homebrew** so that Claude Desktop will detect it: `brew install uv`. Installing `uv` with other methods may not make it accessible to Claude Desktop. + The `install` command currently only sets up servers for STDIO transport. When installed in the Claude desktop app, your server will be run using STDIO regardless of any transport configuration in your code. From 188974d55e8ca3e03502c948badf1667622ebd83 Mon Sep 17 00:00:00 2001 From: jfouret Date: Tue, 27 May 2025 10:45:52 +0200 Subject: [PATCH 093/114] add init_timeout for mcp client --- src/fastmcp/client/client.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index c5293fb4b..4a36c6032 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -62,6 +62,8 @@ class Client: message_handler: Optional handler for protocol messages progress_handler: Optional handler for progress notifications timeout: Optional timeout for requests (seconds or timedelta) + init_timeout: Optional timeout for initial connection (seconds or + timedelta) Examples: ```python @@ -93,6 +95,7 @@ class Client: message_handler: MessageHandler | None = None, progress_handler: ProgressHandler | None = None, timeout: datetime.timedelta | float | int | None = None, + init_timeout: datetime.timedelta | float | int | None = 1, ): self.transport = infer_transport(transport) self._session: ClientSession | None = None @@ -110,6 +113,17 @@ class Client: if isinstance(timeout, int | float): timeout = datetime.timedelta(seconds=timeout) + + if isinstance(init_timeout, int): + self._init_timeout = float(init_timeout) + elif isinstance(init_timeout, datetime.timedelta): + self._init_timeout = float(init_timeout.total_seconds()) + elif isinstance(init_timeout, float): + self._init_timeout = init_timeout + else: + raise ValueError( + "init_timeout must be int, float or datetime.timedelta" + ) self._session_kwargs: SessionKwargs = { "sampling_callback": None, @@ -168,7 +182,7 @@ class Client: self._session = session # Initialize the session try: - with anyio.fail_after(1): + with anyio.fail_after(self._init_timeout): self._initialize_result = await self._session.initialize() yield except TimeoutError: From 68636536dfc2d7cbb313c73d43cdfb688b16a05b Mon Sep 17 00:00:00 2001 From: jfouret Date: Tue, 27 May 2025 10:56:01 +0200 Subject: [PATCH 094/114] ruff format src/fastmcp/client/client.py --- src/fastmcp/client/client.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 4a36c6032..51ca9c5a7 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -62,7 +62,7 @@ class Client: message_handler: Optional handler for protocol messages progress_handler: Optional handler for progress notifications timeout: Optional timeout for requests (seconds or timedelta) - init_timeout: Optional timeout for initial connection (seconds or + init_timeout: Optional timeout for initial connection (seconds or timedelta) Examples: @@ -113,7 +113,7 @@ class Client: if isinstance(timeout, int | float): timeout = datetime.timedelta(seconds=timeout) - + if isinstance(init_timeout, int): self._init_timeout = float(init_timeout) elif isinstance(init_timeout, datetime.timedelta): @@ -121,9 +121,7 @@ class Client: elif isinstance(init_timeout, float): self._init_timeout = init_timeout else: - raise ValueError( - "init_timeout must be int, float or datetime.timedelta" - ) + raise ValueError("init_timeout must be int, float or datetime.timedelta") self._session_kwargs: SessionKwargs = { "sampling_callback": None, From fc110e46d43fb3a3403eb35388a349dba72a44f2 Mon Sep 17 00:00:00 2001 From: jfouret Date: Tue, 27 May 2025 11:00:41 +0200 Subject: [PATCH 095/114] types, None type impossible for init_timeout --- src/fastmcp/client/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 51ca9c5a7..7e8c106e8 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -95,7 +95,7 @@ class Client: message_handler: MessageHandler | None = None, progress_handler: ProgressHandler | None = None, timeout: datetime.timedelta | float | int | None = None, - init_timeout: datetime.timedelta | float | int | None = 1, + init_timeout: datetime.timedelta | float | int = 1, ): self.transport = infer_transport(transport) self._session: ClientSession | None = None From b7607af17a57be061704a51049def8e6b536c42f Mon Sep 17 00:00:00 2001 From: davenpi Date: Mon, 26 May 2025 17:42:23 -0400 Subject: [PATCH 096/114] Handle unreachable mounted servers. --- src/fastmcp/server/server.py | 94 +++++++++++++++++++++++++++--------- tests/server/test_mount.py | 77 ++++++++++++++++++++++++++++- 2 files changed, 146 insertions(+), 25 deletions(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 89d760370..2df709b6d 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -257,9 +257,15 @@ class FastMCP(Generic[LifespanResultT]): """Get all registered tools, indexed by registered key.""" if (tools := self._cache.get("tools")) is self._cache.NOT_FOUND: tools: dict[str, Tool] = {} - for server in self._mounted_servers.values(): - server_tools = await server.get_tools() - tools.update(server_tools) + for prefix, server in self._mounted_servers.items(): + try: + server_tools = await server.get_tools() + tools.update(server_tools) + except Exception as e: + logger.warning( + f"Failed to get tools from mounted server '{prefix}': {e}" + ) + continue tools.update(self._tool_manager.get_tools()) self._cache.set("tools", tools) return tools @@ -268,9 +274,15 @@ class FastMCP(Generic[LifespanResultT]): """Get all registered resources, indexed by registered key.""" if (resources := self._cache.get("resources")) is self._cache.NOT_FOUND: resources: dict[str, Resource] = {} - for server in self._mounted_servers.values(): - server_resources = await server.get_resources() - resources.update(server_resources) + for prefix, server in self._mounted_servers.items(): + try: + server_resources = await server.get_resources() + resources.update(server_resources) + except Exception as e: + logger.warning( + f"Failed to get resources from mounted server '{prefix}': {e}" + ) + continue resources.update(self._resource_manager.get_resources()) self._cache.set("resources", resources) return resources @@ -281,9 +293,16 @@ class FastMCP(Generic[LifespanResultT]): templates := self._cache.get("resource_templates") ) is self._cache.NOT_FOUND: templates: dict[str, ResourceTemplate] = {} - for server in self._mounted_servers.values(): - server_templates = await server.get_resource_templates() - templates.update(server_templates) + for prefix, server in self._mounted_servers.items(): + try: + server_templates = await server.get_resource_templates() + templates.update(server_templates) + except Exception as e: + logger.warning( + "Failed to get resource templates from mounted server " + f"'{prefix}': {e}" + ) + continue templates.update(self._resource_manager.get_templates()) self._cache.set("resource_templates", templates) return templates @@ -294,9 +313,15 @@ class FastMCP(Generic[LifespanResultT]): """ if (prompts := self._cache.get("prompts")) is self._cache.NOT_FOUND: prompts: dict[str, Prompt] = {} - for server in self._mounted_servers.values(): - server_prompts = await server.get_prompts() - prompts.update(server_prompts) + for prefix, server in self._mounted_servers.items(): + try: + server_prompts = await server.get_prompts() + prompts.update(server_prompts) + except Exception as e: + logger.warning( + f"Failed to get prompts from mounted server '{prefix}': {e}" + ) + continue prompts.update(self._prompt_manager.get_prompts()) self._cache.set("prompts", prompts) return prompts @@ -407,10 +432,16 @@ class FastMCP(Generic[LifespanResultT]): return await self._tool_manager.call_tool(key, arguments) # Check mounted servers to see if they have the tool - for server in self._mounted_servers.values(): - if server.match_tool(key): - tool_key = server.strip_tool_prefix(key) - return await server.server._mcp_call_tool(tool_key, arguments) + for prefix, server in self._mounted_servers.items(): + try: + if server.match_tool(key): + tool_key = server.strip_tool_prefix(key) + return await server.server._mcp_call_tool(tool_key, arguments) + except Exception as e: + logger.warning( + f"Failed to call tool from mounted server '{prefix}': {e}" + ) + continue raise NotFoundError(f"Unknown tool: {key}") @@ -430,10 +461,17 @@ class FastMCP(Generic[LifespanResultT]): ) ] else: - for server in self._mounted_servers.values(): - if server.match_resource(str(uri)): - new_uri = server.strip_resource_prefix(str(uri)) - return await server.server._mcp_read_resource(new_uri) + for prefix, server in self._mounted_servers.items(): + try: + if server.match_resource(str(uri)): + new_uri = server.strip_resource_prefix(str(uri)) + return await server.server._mcp_read_resource(new_uri) + except Exception as e: + logger.warning( + "Failed to read resource from mounted server" + f" '{prefix}': {e}" + ) + continue else: raise NotFoundError(f"Unknown resource: {uri}") @@ -458,10 +496,18 @@ class FastMCP(Generic[LifespanResultT]): return await self._prompt_manager.render_prompt(name, arguments) # Check mounted servers to see if they have the prompt - for server in self._mounted_servers.values(): - if server.match_prompt(name): - prompt_name = server.strip_prompt_prefix(name) - return await server.server._mcp_get_prompt(prompt_name, arguments) + for prefix, server in self._mounted_servers.items(): + try: + if server.match_prompt(name): + prompt_name = server.strip_prompt_prefix(name) + return await server.server._mcp_get_prompt( + prompt_name, arguments + ) + except Exception as e: + logger.warning( + f"Failed to get prompt from mounted server '{prefix}': {e}" + ) + continue raise NotFoundError(f"Unknown prompt: {name}") diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py index 598cf3aa0..a04a14edf 100644 --- a/tests/server/test_mount.py +++ b/tests/server/test_mount.py @@ -1,4 +1,5 @@ import json +import sys from contextlib import asynccontextmanager import pytest @@ -7,7 +8,7 @@ from mcp.types import TextContent, TextResourceContents from fastmcp import FastMCP from fastmcp.client import Client -from fastmcp.client.transports import FastMCPTransport +from fastmcp.client.transports import FastMCPTransport, SSETransport from fastmcp.exceptions import NotFoundError from fastmcp.server.proxy import FastMCPProxy @@ -182,6 +183,80 @@ class TestMultipleServerMount: # Second app's tool should be accessible assert "api_second_tool" in tools + @pytest.mark.skipif( + sys.platform == "win32", reason="Windows asyncio networking timeouts." + ) + async def test_mount_with_unreachable_proxy_servers(self, caplog): + """Test graceful handling when multiple mounted servers fail to connect.""" + + main_app = FastMCP("MainApp") + working_app = FastMCP("WorkingApp") + + @working_app.tool() + def working_tool() -> str: + return "Working tool" + + @working_app.resource(uri="working://data") + def working_resource(): + return "Working resource" + + @working_app.prompt() + def working_prompt() -> str: + return "Working prompt" + + # Mount the working server + main_app.mount("working", working_app) + + # Use an unreachable port + unreachable_client = Client( + transport=SSETransport("http://127.0.0.1:99999/sse") + ) + + # Create a proxy server that will fail to connect + unreachable_proxy = FastMCP.as_proxy(unreachable_client) + + # Mount the unreachable proxy + main_app.mount("unreachable", unreachable_proxy) + + # All object types should work from working server despite unreachable proxy + async with Client(main_app) as client: + # Test tools + tools = await client.list_tools() + tool_names = [tool.name for tool in tools] + assert "working_working_tool" in tool_names + + # Test calling a tool + result = await client.call_tool("working_working_tool", {}) + assert isinstance(result[0], TextContent) + assert result[0].text == "Working tool" + + # Test resources + resources = await client.list_resources() + resource_uris = [str(resource.uri) for resource in resources] + assert "working://working/data" in resource_uris + + # Test prompts + prompts = await client.list_prompts() + prompt_names = [prompt.name for prompt in prompts] + assert "working_working_prompt" in prompt_names + + # Verify that warnings were logged for the unreachable server + warning_messages = [ + record.message for record in caplog.records if record.levelname == "WARNING" + ] + assert any( + "Failed to get tools from mounted server 'unreachable'" in msg + for msg in warning_messages + ) + assert any( + "Failed to get resources from mounted server 'unreachable'" in msg + for msg in warning_messages + ) + assert any( + "Failed to get prompts from mounted server 'unreachable'" in msg + for msg in warning_messages + ) + class TestDynamicChanges: """Test that changes to mounted servers are reflected dynamically.""" From a392f78d0760f57aab00d6b1e27e539081018deb Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 27 May 2025 08:43:31 -0400 Subject: [PATCH 097/114] Add global setting --- src/fastmcp/client/client.py | 49 ++++++++++++++++++------------------ src/fastmcp/settings.py | 8 ++++++ 2 files changed, 33 insertions(+), 24 deletions(-) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 7e8c106e8..b177b950a 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -9,6 +9,7 @@ from exceptiongroup import catch from mcp import ClientSession from pydantic import AnyUrl +import fastmcp from fastmcp.client.logging import ( LogHandler, MessageHandler, @@ -45,8 +46,8 @@ class Client: MCP client that delegates connection management to a Transport instance. The Client class is responsible for MCP protocol logic, while the Transport - handles connection establishment and management. Client provides methods - for working with resources, prompts, tools and other MCP capabilities. + handles connection establishment and management. Client provides methods for + working with resources, prompts, tools and other MCP capabilities. Args: transport: Connection source specification, which can be: @@ -57,25 +58,23 @@ class Client: - MCPConfig: MCP server configuration - dict: Transport configuration roots: Optional RootsList or RootsHandler for filesystem access - sampling_handler: Optional handler for sampling requests - log_handler: Optional handler for log messages - message_handler: Optional handler for protocol messages - progress_handler: Optional handler for progress notifications - timeout: Optional timeout for requests (seconds or timedelta) - init_timeout: Optional timeout for initial connection (seconds or - timedelta) + sampling_handler: Optional handler for sampling requests log_handler: + Optional handler for log messages message_handler: Optional handler for + protocol messages progress_handler: Optional handler for progress + notifications timeout: Optional timeout for requests (seconds or + timedelta) init_timeout: Optional timeout for initial connection + (seconds or timedelta). Set to 0 to disable. If None, uses the value + in the FastMCP global settings. Examples: - ```python - # Connect to FastMCP server - client = Client("http://localhost:8080") + ```python # Connect to FastMCP server client = + Client("http://localhost:8080") async with client: - # List available resources - resources = await client.list_resources() + # List available resources resources = await client.list_resources() - # Call a tool - result = await client.call_tool("my_tool", {"param": "value"}) + # Call a tool result = await client.call_tool("my_tool", {"param": + "value"}) ``` """ @@ -95,7 +94,7 @@ class Client: message_handler: MessageHandler | None = None, progress_handler: ProgressHandler | None = None, timeout: datetime.timedelta | float | int | None = None, - init_timeout: datetime.timedelta | float | int = 1, + init_timeout: datetime.timedelta | float | int | None = None, ): self.transport = infer_transport(transport) self._session: ClientSession | None = None @@ -114,14 +113,16 @@ class Client: if isinstance(timeout, int | float): timeout = datetime.timedelta(seconds=timeout) - if isinstance(init_timeout, int): - self._init_timeout = float(init_timeout) - elif isinstance(init_timeout, datetime.timedelta): - self._init_timeout = float(init_timeout.total_seconds()) - elif isinstance(init_timeout, float): - self._init_timeout = init_timeout + # handle init handshake timeout + if init_timeout is None: + init_timeout = fastmcp.settings.settings.client_init_timeout + if isinstance(init_timeout, datetime.timedelta): + init_timeout = init_timeout.total_seconds() + elif not init_timeout: + init_timeout = None else: - raise ValueError("init_timeout must be int, float or datetime.timedelta") + init_timeout = float(init_timeout) + self._init_timeout = init_timeout self._session_kwargs: SessionKwargs = { "sampling_callback": None, diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index 662b0db6f..30b7a7461 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -87,6 +87,14 @@ class Settings(BaseSettings): ), ] = False + client_init_timeout: Annotated[ + float | None, + Field( + default=1, + description="The timeout for the client's initialization handshake, in seconds. Set to None or 0 to disable.", + ), + ] = None + @model_validator(mode="after") def setup_logging(self) -> Self: """Finalize the settings.""" From 8236b4a1fe8aa7dc9420c72fff092523b30c4a32 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 27 May 2025 08:46:24 -0400 Subject: [PATCH 098/114] Update settings.py --- src/fastmcp/settings.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index 30b7a7461..c872ecb2a 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -90,7 +90,6 @@ class Settings(BaseSettings): client_init_timeout: Annotated[ float | None, Field( - default=1, description="The timeout for the client's initialization handshake, in seconds. Set to None or 0 to disable.", ), ] = None From bf68db2df002bb2dda309bf676c792925de8f56a Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 27 May 2025 08:47:04 -0400 Subject: [PATCH 099/114] Fix docstring --- src/fastmcp/client/client.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index b177b950a..853f68c79 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -58,13 +58,13 @@ class Client: - MCPConfig: MCP server configuration - dict: Transport configuration roots: Optional RootsList or RootsHandler for filesystem access - sampling_handler: Optional handler for sampling requests log_handler: - Optional handler for log messages message_handler: Optional handler for - protocol messages progress_handler: Optional handler for progress - notifications timeout: Optional timeout for requests (seconds or - timedelta) init_timeout: Optional timeout for initial connection - (seconds or timedelta). Set to 0 to disable. If None, uses the value - in the FastMCP global settings. + sampling_handler: Optional handler for sampling requests + log_handler: Optional handler for log messages + message_handler: Optional handler for protocol messages + progress_handler: Optional handler for progress notifications + timeout: Optional timeout for requests (seconds or timedelta) + init_timeout: Optional timeout for initial connection (seconds or timedelta). + Set to 0 to disable. If None, uses the value in the FastMCP global settings. Examples: ```python # Connect to FastMCP server client = From 05288fc507b02f60f94b2c1efa65d95b7619fe7c Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 27 May 2025 09:08:24 -0400 Subject: [PATCH 100/114] Fix handling tools without descriptions --- src/fastmcp/resources/template.py | 2 +- src/fastmcp/tools/tool.py | 4 ++-- tests/server/test_proxy.py | 9 +++++++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index b7adb9481..5077a910b 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -148,7 +148,7 @@ class ResourceTemplate(BaseModel): f"URI parameters {uri_params} must be a subset of the function arguments: {func_params}" ) - description = description or fn.__doc__ or "" + description = description or fn.__doc__ if not inspect.isroutine(fn): fn = fn.__call__ diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index e92eda26b..0658a0dad 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -36,7 +36,7 @@ class Tool(BaseModel): fn: Callable[..., Any] name: str = Field(description="Name of the tool") - description: str = Field(description="Description of what the tool does") + description: str | None = Field(description="Description of what the tool does") parameters: dict[str, Any] = Field(description="JSON schema for tool parameters") tags: Annotated[set[str], BeforeValidator(_convert_set_defaults)] = Field( default_factory=set, description="Tags for the tool" @@ -74,7 +74,7 @@ class Tool(BaseModel): if func_name == "": raise ValueError("You must provide a name for lambda functions") - func_doc = description or fn.__doc__ or "" + func_doc = description or fn.__doc__ # if the fn is a callable class, we need to get the __call__ method from here out if not inspect.isroutine(fn): diff --git a/tests/server/test_proxy.py b/tests/server/test_proxy.py index b77db9b38..f310fa505 100644 --- a/tests/server/test_proxy.py +++ b/tests/server/test_proxy.py @@ -30,6 +30,10 @@ def fastmcp_server(): """Greet someone by name.""" return f"Hello, {name}!" + @server.tool() + def tool_without_description() -> str: + return "Hello?" + @server.tool() def add(a: int, b: int) -> int: """Add two numbers together.""" @@ -110,6 +114,11 @@ class TestTools: assert "greet" in tools assert "add" in tools assert "error_tool" in tools + assert "tool_without_description" in tools + + async def test_tool_without_description(self, proxy_server): + tools = await proxy_server.get_tools() + assert tools["tool_without_description"].description is None async def test_list_tools_same_as_original(self, fastmcp_server, proxy_server): assert ( From bfef774c111f654efbd82be0cca9294a3ff3160e Mon Sep 17 00:00:00 2001 From: davenpi Date: Tue, 27 May 2025 09:24:57 -0400 Subject: [PATCH 101/114] Remove exceptions from execution methods. --- src/fastmcp/server/server.py | 45 ++++++++++-------------------------- 1 file changed, 12 insertions(+), 33 deletions(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 2df709b6d..f5e35916b 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -432,16 +432,10 @@ class FastMCP(Generic[LifespanResultT]): return await self._tool_manager.call_tool(key, arguments) # Check mounted servers to see if they have the tool - for prefix, server in self._mounted_servers.items(): - try: - if server.match_tool(key): - tool_key = server.strip_tool_prefix(key) - return await server.server._mcp_call_tool(tool_key, arguments) - except Exception as e: - logger.warning( - f"Failed to call tool from mounted server '{prefix}': {e}" - ) - continue + for server in self._mounted_servers.values(): + if server.match_tool(key): + tool_key = server.strip_tool_prefix(key) + return await server.server._mcp_call_tool(tool_key, arguments) raise NotFoundError(f"Unknown tool: {key}") @@ -461,17 +455,10 @@ class FastMCP(Generic[LifespanResultT]): ) ] else: - for prefix, server in self._mounted_servers.items(): - try: - if server.match_resource(str(uri)): - new_uri = server.strip_resource_prefix(str(uri)) - return await server.server._mcp_read_resource(new_uri) - except Exception as e: - logger.warning( - "Failed to read resource from mounted server" - f" '{prefix}': {e}" - ) - continue + for server in self._mounted_servers.values(): + if server.match_resource(str(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}") @@ -496,18 +483,10 @@ class FastMCP(Generic[LifespanResultT]): return await self._prompt_manager.render_prompt(name, arguments) # Check mounted servers to see if they have the prompt - for prefix, server in self._mounted_servers.items(): - try: - if server.match_prompt(name): - prompt_name = server.strip_prompt_prefix(name) - return await server.server._mcp_get_prompt( - prompt_name, arguments - ) - except Exception as e: - logger.warning( - f"Failed to get prompt from mounted server '{prefix}': {e}" - ) - continue + for server in self._mounted_servers.values(): + if server.match_prompt(name): + prompt_name = server.strip_prompt_prefix(name) + return await server.server._mcp_get_prompt(prompt_name, arguments) raise NotFoundError(f"Unknown prompt: {name}") From 4671741d42a06c33c85c0d14b6008d89e8029174 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 27 May 2025 13:03:15 -0400 Subject: [PATCH 102/114] Update src/fastmcp/tools/tool.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/fastmcp/tools/tool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index 0658a0dad..f9beaeea9 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -36,7 +36,7 @@ class Tool(BaseModel): fn: Callable[..., Any] name: str = Field(description="Name of the tool") - description: str | None = Field(description="Description of what the tool does") + description: str | None = Field(default=None, description="Description of what the tool does") parameters: dict[str, Any] = Field(description="JSON schema for tool parameters") tags: Annotated[set[str], BeforeValidator(_convert_set_defaults)] = Field( default_factory=set, description="Tags for the tool" From 32f6945336eb9b3ee989445cceebc4a21d41b7f7 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 27 May 2025 13:04:17 -0400 Subject: [PATCH 103/114] Update tool.py --- src/fastmcp/tools/tool.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index f9beaeea9..f556bafa7 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -36,7 +36,9 @@ class Tool(BaseModel): fn: Callable[..., Any] name: str = Field(description="Name of the tool") - description: str | None = Field(default=None, description="Description of what the tool does") + description: str | None = Field( + default=None, description="Description of what the tool does" + ) parameters: dict[str, Any] = Field(description="JSON schema for tool parameters") tags: Annotated[set[str], BeforeValidator(_convert_set_defaults)] = Field( default_factory=set, description="Tags for the tool" From 2b547309ec257feb5ac5bce3b7e893e73e795ae2 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 28 May 2025 08:40:22 -0400 Subject: [PATCH 104/114] Don't print env vars to console when format is wrong --- src/fastmcp/cli/cli.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py index 819144d22..9e2dfd719 100644 --- a/src/fastmcp/cli/cli.py +++ b/src/fastmcp/cli/cli.py @@ -50,9 +50,7 @@ def _get_npx_command(): def _parse_env_var(env_var: str) -> tuple[str, str]: """Parse environment variable string in format KEY=VALUE.""" if "=" not in env_var: - logger.error( - f"Invalid environment variable format: {env_var}. Must be KEY=VALUE" - ) + logger.error("Invalid environment variable format. Must be KEY=VALUE") sys.exit(1) key, value = env_var.split("=", 1) return key.strip(), value.strip() From d311a2b8789156b1b451203ca15b6117b00ed72f Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 28 May 2025 18:30:33 -0400 Subject: [PATCH 105/114] Ensure behavior-affecting headers are excluded --- docs/patterns/http-requests.mdx | 2 +- src/fastmcp/server/dependencies.py | 23 +++++++++++++++----- tests/client/test_openapi.py | 35 ++++++++++++++++++++++++++---- 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/docs/patterns/http-requests.mdx b/docs/patterns/http-requests.mdx index dd39deabc..ceb333e19 100644 --- a/docs/patterns/http-requests.mdx +++ b/docs/patterns/http-requests.mdx @@ -77,7 +77,7 @@ async def safe_header_info() -> dict: } ``` -By default, `get_http_headers()` excludes problematic headers like `content-length`. To include all headers, use `get_http_headers(include_all=True)`. +By default, `get_http_headers()` excludes problematic headers like `host` and `content-length`. To include all headers, use `get_http_headers(include_all=True)`. ## Important Notes diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py index 8448a283d..7072114cc 100644 --- a/src/fastmcp/server/dependencies.py +++ b/src/fastmcp/server/dependencies.py @@ -48,11 +48,24 @@ def get_http_headers(include_all: bool = False) -> dict[str, str]: if include_all: exclude_headers = set() else: - exclude_headers = {"content-length"} - - # ensure all lowercase! - # (just in case) - exclude_headers = {h.lower() for h in exclude_headers} + exclude_headers = { + "host", + "content-length", + "connection", + "transfer-encoding", + "upgrade", + "te", + "keep-alive", + "expect", + # Proxy-related headers + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + } + # (just in case) + assert all(h.lower() == h for h in exclude_headers), ( + "Excluded headers must be lowercase" + ) headers = {} diff --git a/tests/client/test_openapi.py b/tests/client/test_openapi.py index be2334cf7..e9b704ece 100644 --- a/tests/client/test_openapi.py +++ b/tests/client/test_openapi.py @@ -28,7 +28,8 @@ def fastmcp_server_for_headers() -> FastMCP: return request.headers mcp = FastMCP.from_fastapi( - app, httpx_client_kwargs={"headers": {"X-SERVER": "test-abc"}} + app, + httpx_client_kwargs={"headers": {"x-server-header": "test-abc"}}, ) return mcp @@ -169,16 +170,42 @@ class TestClientHeaders: headers = json.loads(result[0].text) assert headers["x-test"] == "test-123" + # async def test_certain_client_headers_are_stripped(self, shttp_server: str): + # async with Client( + # transport=StreamableHttpTransport(shttp_server, headers={"host": "1.2.3.4"}) + # ) as client: + # result = await client.read_resource("resource://get_headers_headers_get") + # assert isinstance(result[0], TextResourceContents) + # headers = json.loads(result[0].text) + # assert "host" not in headers + async def test_client_overrides_server_headers(self, shttp_server: str): async with Client( transport=StreamableHttpTransport( - shttp_server, headers={"X-SERVER": "test-client"} + shttp_server, headers={"x-server-header": "test-client"} ) ) as client: result = await client.read_resource("resource://get_headers_headers_get") assert isinstance(result[0], TextResourceContents) headers = json.loads(result[0].text) - assert headers["x-server"] == "test-client" + assert headers["x-server-header"] == "test-client" + + async def test_client_with_excluded_header_is_ignored(self, sse_server: str): + async with Client( + transport=SSETransport( + sse_server, + headers={ + "x-server-header": "test-client", + "host": "1.2.3.4", + "not-host": "1.2.3.4", + }, + ) + ) as client: + result = await client.read_resource("resource://get_headers_headers_get") + assert isinstance(result[0], TextResourceContents) + headers = json.loads(result[0].text) + assert headers["not-host"] == "1.2.3.4" + assert headers["host"] == "fastapi" async def test_client_headers_proxy(self, proxy_server: str): """ @@ -188,4 +215,4 @@ class TestClientHeaders: result = await client.read_resource("resource://get_headers_headers_get") assert isinstance(result[0], TextResourceContents) headers = json.loads(result[0].text) - assert headers["x-server"] == "test-abc" + assert headers["x-server-header"] == "test-abc" From ee059b02b66efee7f102dc3db5af87b4873d95b1 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 28 May 2025 18:38:16 -0400 Subject: [PATCH 106/114] Update test_openapi.py --- tests/client/test_openapi.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/tests/client/test_openapi.py b/tests/client/test_openapi.py index e9b704ece..0a05bb00d 100644 --- a/tests/client/test_openapi.py +++ b/tests/client/test_openapi.py @@ -170,15 +170,6 @@ class TestClientHeaders: headers = json.loads(result[0].text) assert headers["x-test"] == "test-123" - # async def test_certain_client_headers_are_stripped(self, shttp_server: str): - # async with Client( - # transport=StreamableHttpTransport(shttp_server, headers={"host": "1.2.3.4"}) - # ) as client: - # result = await client.read_resource("resource://get_headers_headers_get") - # assert isinstance(result[0], TextResourceContents) - # headers = json.loads(result[0].text) - # assert "host" not in headers - async def test_client_overrides_server_headers(self, shttp_server: str): async with Client( transport=StreamableHttpTransport( From ed2b9176cdaa7cc121a50654a2a6429bb98047bb Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 28 May 2025 18:43:41 -0400 Subject: [PATCH 107/114] Update src/fastmcp/server/dependencies.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/fastmcp/server/dependencies.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py index 7072114cc..e2d279dc5 100644 --- a/src/fastmcp/server/dependencies.py +++ b/src/fastmcp/server/dependencies.py @@ -63,10 +63,8 @@ def get_http_headers(include_all: bool = False) -> dict[str, str]: "proxy-connection", } # (just in case) - assert all(h.lower() == h for h in exclude_headers), ( - "Excluded headers must be lowercase" - ) - + if not all(h.lower() == h for h in exclude_headers): + raise ValueError("Excluded headers must be lowercase") headers = {} try: From 432dd4dd2d32c9c66158d9516480c565e78d4d36 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 28 May 2025 21:05:34 -0400 Subject: [PATCH 108/114] Improve type inference from client transport --- src/fastmcp/client/client.py | 59 +++++++++++++++++++++++++++++--- src/fastmcp/client/transports.py | 43 ++++++++++++++++++++++- src/fastmcp/server/server.py | 8 +++-- 3 files changed, 101 insertions(+), 9 deletions(-) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 853f68c79..dbb5b1793 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -1,7 +1,7 @@ import datetime from contextlib import AsyncExitStack, asynccontextmanager from pathlib import Path -from typing import Any, cast +from typing import Any, Generic, cast, overload import anyio import mcp.types @@ -28,7 +28,18 @@ from fastmcp.server import FastMCP from fastmcp.utilities.exceptions import get_catch_handlers from fastmcp.utilities.mcp_config import MCPConfig -from .transports import ClientTransport, SessionKwargs, infer_transport +from .transports import ( + ClientTransportT, + FastMCP1Server, + FastMCPTransport, + MCPConfigTransport, + NodeStdioTransport, + PythonStdioTransport, + SessionKwargs, + SSETransport, + StreamableHttpTransport, + infer_transport, +) __all__ = [ "Client", @@ -41,7 +52,7 @@ __all__ = [ ] -class Client: +class Client(Generic[ClientTransportT]): """ MCP client that delegates connection management to a Transport instance. @@ -78,9 +89,47 @@ class Client: ``` """ + @overload + def __new__( + cls, + transport: ClientTransportT, + **kwargs: Any, + ) -> "Client[ClientTransportT]": ... + + @overload + def __new__( + cls, transport: AnyUrl, **kwargs + ) -> "Client[SSETransport|StreamableHttpTransport]": ... + + @overload + def __new__( + cls, transport: FastMCP | FastMCP1Server, **kwargs + ) -> "Client[FastMCPTransport]": ... + + @overload + def __new__( + cls, transport: Path, **kwargs + ) -> "Client[PythonStdioTransport|NodeStdioTransport]": ... + + @overload + def __new__( + cls, transport: MCPConfig | dict[str, Any], **kwargs + ) -> "Client[MCPConfigTransport]": ... + + @overload + def __new__( + cls, transport: str, **kwargs + ) -> "Client[PythonStdioTransport|NodeStdioTransport|SSETransport|StreamableHttpTransport]": ... + + def __new__(cls, transport, **kwargs) -> "Client": + instance = super().__new__(cls) + return instance + + transport: ClientTransportT + def __init__( self, - transport: ClientTransport + transport: ClientTransportT | FastMCP | AnyUrl | Path @@ -96,7 +145,7 @@ class Client: timeout: datetime.timedelta | float | int | None = None, init_timeout: datetime.timedelta | float | int | None = None, ): - self.transport = infer_transport(transport) + self.transport = infer_transport(transport) # type: ignore self._session: ClientSession | None = None self._exit_stack: AsyncExitStack | None = None self._nesting_counter: int = 0 diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index e43ab5760..9a7268340 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -6,7 +6,7 @@ import shutil import sys from collections.abc import AsyncIterator from pathlib import Path -from typing import TYPE_CHECKING, Any, TypedDict, cast +from typing import TYPE_CHECKING, Any, TypedDict, TypeVar, cast, overload from mcp import ClientSession, StdioServerParameters from mcp.client.session import ( @@ -35,6 +35,9 @@ if TYPE_CHECKING: logger = get_logger(__name__) +# TypeVar for preserving specific ClientTransport subclass types +ClientTransportT = TypeVar("ClientTransportT", bound="ClientTransport") + class SessionKwargs(TypedDict, total=False): """Keyword arguments for the MCP ClientSession constructor.""" @@ -575,6 +578,44 @@ class MCPConfigTransport(ClientTransport): return f"" +@overload +def infer_transport(transport: ClientTransportT) -> ClientTransportT: ... + + +@overload +def infer_transport(transport: FastMCPServer) -> FastMCPTransport: ... + + +@overload +def infer_transport(transport: FastMCP1Server) -> FastMCPTransport: ... + + +@overload +def infer_transport(transport: MCPConfig) -> MCPConfigTransport: ... + + +@overload +def infer_transport(transport: dict[str, Any]) -> MCPConfigTransport: ... + + +@overload +def infer_transport( + transport: AnyUrl, +) -> SSETransport | StreamableHttpTransport: ... + + +@overload +def infer_transport( + transport: str, +) -> ( + PythonStdioTransport | NodeStdioTransport | SSETransport | StreamableHttpTransport +): ... + + +@overload +def infer_transport(transport: Path) -> PythonStdioTransport | NodeStdioTransport: ... + + def infer_transport( transport: ClientTransport | FastMCPServer diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index f5e35916b..183c4927f 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -62,7 +62,7 @@ from fastmcp.utilities.mcp_config import MCPConfig if TYPE_CHECKING: from fastmcp.client import Client - from fastmcp.client.transports import ClientTransport + from fastmcp.client.transports import ClientTransport, ClientTransportT from fastmcp.server.openapi import ComponentFn as OpenAPIComponentFn from fastmcp.server.openapi import FastMCPOpenAPI, RouteMap from fastmcp.server.openapi import RouteMapFn as OpenAPIRouteMapFn @@ -1288,7 +1288,7 @@ class FastMCP(Generic[LifespanResultT]): @classmethod def as_proxy( cls, - backend: Client + backend: Client[ClientTransportT] | ClientTransport | FastMCP[Any] | AnyUrl @@ -1316,7 +1316,9 @@ class FastMCP(Generic[LifespanResultT]): return FastMCPProxy(client=client, **settings) @classmethod - def from_client(cls, client: Client, **settings: Any) -> FastMCPProxy: + def from_client( + cls, client: Client[ClientTransportT], **settings: Any + ) -> FastMCPProxy: """ Create a FastMCP proxy server from a FastMCP client. """ From a3450b4b2585d7ffceda2ed37361cea9088226c6 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 28 May 2025 21:15:47 -0400 Subject: [PATCH 109/114] Improve type inference --- src/fastmcp/client/client.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index dbb5b1793..a33969c8a 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -125,8 +125,6 @@ class Client(Generic[ClientTransportT]): instance = super().__new__(cls) return instance - transport: ClientTransportT - def __init__( self, transport: ClientTransportT @@ -145,7 +143,7 @@ class Client(Generic[ClientTransportT]): timeout: datetime.timedelta | float | int | None = None, init_timeout: datetime.timedelta | float | int | None = None, ): - self.transport = infer_transport(transport) # type: ignore + self.transport = cast(ClientTransportT, infer_transport(transport)) self._session: ClientSession | None = None self._exit_stack: AsyncExitStack | None = None self._nesting_counter: int = 0 From 9bbd4171295bdbeecf6ad351d35ba5bdfa26cc51 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 28 May 2025 21:46:40 -0400 Subject: [PATCH 110/114] Add keep_alive param to reuse subprocess --- docs/clients/client.mdx | 70 +++++++++++++---- docs/clients/transports.mdx | 59 +++++++++++++- src/fastmcp/client/client.py | 6 ++ src/fastmcp/client/transports.py | 130 +++++++++++++++++++++++++++---- tests/client/test_stdio.py | 129 ++++++++++++++++++++++++++++++ 5 files changed, 366 insertions(+), 28 deletions(-) create mode 100644 tests/client/test_stdio.py diff --git a/docs/clients/client.mdx b/docs/clients/client.mdx index c53ecee03..74ee9e3b4 100644 --- a/docs/clients/client.mdx +++ b/docs/clients/client.mdx @@ -18,18 +18,6 @@ The FastMCP Client architecture separates the protocol logic (`Client`) from the - **`Client`**: Handles sending MCP requests (like `tools/call`, `resources/read`), receiving responses, and managing callbacks. - **`Transport`**: Responsible for establishing and maintaining the connection to the server (e.g., via WebSockets, SSE, Stdio, or in-memory). -```python -from fastmcp import Client, FastMCP -from fastmcp.client import ( - RootsHandler, - RootsList, - LogHandler, - MessageHandler, - SamplingHandler, - ProgressHandler # For handling progress notifications -) -``` - ### Transports Clients must be initialized with a `transport`. You can either provide an already instantiated transport object, or provide a transport source and let FastMCP attempt to infer the correct transport to use. @@ -282,7 +270,7 @@ These methods are especially useful for debugging or when you need to access met ### Additional Features -#### Pinging the server +#### Pinging the Server The client can be used to ping the server to verify connectivity. @@ -292,6 +280,62 @@ async with client: print("Server is reachable") ``` +#### Session Management + +When using stdio transports, clients support a `keep_alive` feature (enabled by default) that maintains subprocess sessions between connection contexts. You can manually control this behavior using the client's `close()` method. + +When `keep_alive=False`, the client will automatically close the session when the context manager exits. + + +```python keep_alive=True +from fastmcp import Client + +# Client with keep_alive=True (default) +client = Client("my_mcp_server.py") + +async def example(): + # First session + async with client: + await client.ping() + + # Second session - uses the same subprocess + async with client: + await client.ping() + + # Manually close the session + await client.close() + + # Third session - will start a new subprocess + async with client: + await client.ping() + +asyncio.run(example()) +``` +```python keep_alive=False +from fastmcp import Client + +# Client with keep_alive=False +client = Client("my_mcp_server.py", keep_alive=False) + +async def example(): + # First session + async with client: + await client.ping() + + # Second session - will start a new subprocess + async with client: + await client.ping() + + # Third session - will start a new subprocess + async with client: + await client.ping() + +asyncio.run(example()) +``` + + + + #### Timeouts diff --git a/docs/clients/transports.mdx b/docs/clients/transports.mdx index 3669c8b25..6ed9fc489 100644 --- a/docs/clients/transports.mdx +++ b/docs/clients/transports.mdx @@ -160,6 +160,63 @@ client = Client(transport) These transports manage an MCP server running as a subprocess, communicating with it via standard input (stdin) and standard output (stdout). This is the standard mechanism used by clients like Claude Desktop. +### Session Management + +All stdio transports support a `keep_alive` parameter (default: `True`) that controls session persistence across multiple client context managers: + +- **`keep_alive=True` (default)**: The subprocess and session are maintained between client context exits and re-entries. This improves performance when making multiple separate connections to the same server. +- **`keep_alive=False`**: A new subprocess is started for each client context, ensuring complete isolation between sessions. + +When `keep_alive=True`, you can manually close the session using `await client.close()` if needed. This will terminate the subprocess and require a new one to be started on the next connection. + + +```python keep_alive=True +from fastmcp import Client + +# Client with keep_alive=True (default) +client = Client("my_mcp_server.py") + +async def example(): + # First session + async with client: + await client.ping() + + # Second session - uses the same subprocess + async with client: + await client.ping() + + # Manually close the session + await client.close() + + # Third session - will start a new subprocess + async with client: + await client.ping() + +asyncio.run(example()) +``` +```python keep_alive=False +from fastmcp import Client + +# Client with keep_alive=False +client = Client("my_mcp_server.py", keep_alive=False) + +async def example(): + # First session + async with client: + await client.ping() + + # Second session - will start a new subprocess + async with client: + await client.ping() + + # Third session - will start a new subprocess + async with client: + await client.ping() + +asyncio.run(example()) +``` + + ### Python Stdio - **Class:** `fastmcp.client.transports.PythonStdioTransport` @@ -218,7 +275,7 @@ client = Client(node_server_script) # Option 2: Explicit transport transport = NodeStdioTransport( script_path=node_server_script, - node_cmd="node" # Optional: specify path to Node executable + node_cmd="node", # Optional: specify path to Node executable ) client = Client(transport) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index a33969c8a..1fee12ff7 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -194,6 +194,7 @@ class Client(Generic[ClientTransportT]): raise RuntimeError( "Client is not connected. Use the 'async with client:' context manager first." ) + return self._session @property @@ -231,6 +232,8 @@ class Client(Generic[ClientTransportT]): with anyio.fail_after(self._init_timeout): self._initialize_result = await self._session.initialize() yield + except anyio.ClosedResourceError: + raise RuntimeError("Server session was closed unexpectedly") except TimeoutError: raise RuntimeError("Failed to initialize server session") finally: @@ -263,6 +266,9 @@ class Client(Generic[ClientTransportT]): finally: self._exit_stack = None + async def close(self): + await self.transport.close() + # --- MCP Client Methods --- async def ping(self) -> bool: diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 9a7268340..1fa52bf1a 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -1,9 +1,11 @@ import abc +import asyncio import contextlib import datetime import os import shutil import sys +import warnings from collections.abc import AsyncIterator from pathlib import Path from typing import TYPE_CHECKING, Any, TypedDict, TypeVar, cast, overload @@ -86,11 +88,20 @@ class ClientTransport(abc.ABC): # Basic representation for subclasses return f"<{self.__class__.__name__}>" + async def close(self): + """Close the transport.""" + pass + class WSTransport(ClientTransport): """Transport implementation that connects to an MCP server via WebSockets.""" def __init__(self, url: str | AnyUrl): + # we never really used this transport, so it can be removed at any time + warnings.warn( + "WSTransport is a deprecated MCP transport and will be removed in a future version. Use StreamableHttpTransport instead.", + DeprecationWarning, + ) if isinstance(url, AnyUrl): url = str(url) if not isinstance(url, str) or not url.startswith("ws"): @@ -227,6 +238,7 @@ class StdioTransport(ClientTransport): args: list[str], env: dict[str, str] | None = None, cwd: str | None = None, + keep_alive: bool | None = None, ): """ Initialize a Stdio transport. @@ -241,20 +253,81 @@ class StdioTransport(ClientTransport): self.args = args self.env = env self.cwd = cwd + if keep_alive is None: + keep_alive = True + self.keep_alive = keep_alive + + self._session: ClientSession | None = None + self._connect_task: asyncio.Task | None = None + self._ready_event = asyncio.Event() + self._stop_event = asyncio.Event() @contextlib.asynccontextmanager async def connect_session( self, **session_kwargs: Unpack[SessionKwargs] ) -> AsyncIterator[ClientSession]: - server_params = StdioServerParameters( - command=self.command, args=self.args, env=self.env, cwd=self.cwd - ) - async with stdio_client(server_params) as transport: - read_stream, write_stream = transport - async with ClientSession( - read_stream, write_stream, **session_kwargs - ) as session: - yield session + try: + await self.connect(**session_kwargs) + assert self._session is not None + yield self._session + finally: + if not self.keep_alive: + await self.disconnect() + else: + logger.debug("Stdio transport has keep_alive=True, not disconnecting") + + async def connect( + self, **session_kwargs: Unpack[SessionKwargs] + ) -> ClientSession | None: + if self._connect_task is not None: + return + + async def _connect_task(): + async with contextlib.AsyncExitStack() as stack: + try: + server_params = StdioServerParameters( + command=self.command, args=self.args, env=self.env, cwd=self.cwd + ) + transport = await stack.enter_async_context( + stdio_client(server_params) + ) + read_stream, write_stream = transport + self._session = await stack.enter_async_context( + ClientSession(read_stream, write_stream, **session_kwargs) + ) + + logger.debug("Stdio transport connected") + self._ready_event.set() + + # Wait until disconnect is requested (stop_event is set) + await self._stop_event.wait() + finally: + # Clean up client on exit + self._session = None + logger.debug("Stdio transport disconnected") + + # start the connection task + self._connect_task = asyncio.create_task(_connect_task()) + # wait for the client to be ready before returning + await self._ready_event.wait() + + async def disconnect(self): + if self._connect_task is None: + return + + # signal the connection task to stop + self._stop_event.set() + + # wait for the connection task to finish cleanly + await self._connect_task + + # reset variables and events for potential future reconnects + self._connect_task = None + self._stop_event = asyncio.Event() + self._ready_event = asyncio.Event() + + async def close(self): + await self.disconnect() def __repr__(self) -> str: return ( @@ -272,6 +345,7 @@ class PythonStdioTransport(StdioTransport): env: dict[str, str] | None = None, cwd: str | None = None, python_cmd: str = sys.executable, + keep_alive: bool | None = None, ): """ Initialize a Python transport. @@ -293,7 +367,13 @@ class PythonStdioTransport(StdioTransport): if args: full_args.extend(args) - super().__init__(command=python_cmd, args=full_args, env=env, cwd=cwd) + super().__init__( + command=python_cmd, + args=full_args, + env=env, + cwd=cwd, + keep_alive=keep_alive, + ) self.script_path = script_path @@ -306,6 +386,7 @@ class FastMCPStdioTransport(StdioTransport): args: list[str] | None = None, env: dict[str, str] | None = None, cwd: str | None = None, + keep_alive: bool | None = None, ): script_path = Path(script_path).resolve() if not script_path.is_file(): @@ -314,7 +395,11 @@ class FastMCPStdioTransport(StdioTransport): raise ValueError(f"Not a Python script: {script_path}") super().__init__( - command="fastmcp", args=["run", str(script_path)], env=env, cwd=cwd + command="fastmcp", + args=["run", str(script_path)], + env=env, + cwd=cwd, + keep_alive=keep_alive, ) self.script_path = script_path @@ -329,6 +414,7 @@ class NodeStdioTransport(StdioTransport): env: dict[str, str] | None = None, cwd: str | None = None, node_cmd: str = "node", + keep_alive: bool | None = None, ): """ Initialize a Node transport. @@ -350,7 +436,9 @@ class NodeStdioTransport(StdioTransport): if args: full_args.extend(args) - super().__init__(command=node_cmd, args=full_args, env=env, cwd=cwd) + super().__init__( + command=node_cmd, args=full_args, env=env, cwd=cwd, keep_alive=keep_alive + ) self.script_path = script_path @@ -366,6 +454,7 @@ class UvxStdioTransport(StdioTransport): with_packages: list[str] | None = None, from_package: str | None = None, env_vars: dict[str, str] | None = None, + keep_alive: bool | None = None, ): """ Initialize a Uvx transport. @@ -405,7 +494,13 @@ class UvxStdioTransport(StdioTransport): env = os.environ.copy() env.update(env_vars) - super().__init__(command="uvx", args=uvx_args, env=env, cwd=project_directory) + super().__init__( + command="uvx", + args=uvx_args, + env=env, + cwd=project_directory, + keep_alive=keep_alive, + ) self.tool_name = tool_name @@ -419,6 +514,7 @@ class NpxStdioTransport(StdioTransport): project_directory: str | None = None, env_vars: dict[str, str] | None = None, use_package_lock: bool = True, + keep_alive: bool | None = None, ): """ Initialize an Npx transport. @@ -456,7 +552,13 @@ class NpxStdioTransport(StdioTransport): env = os.environ.copy() env.update(env_vars) - super().__init__(command="npx", args=npx_args, env=env, cwd=project_directory) + super().__init__( + command="npx", + args=npx_args, + env=env, + cwd=project_directory, + keep_alive=keep_alive, + ) self.package = package diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py new file mode 100644 index 000000000..55d430f7c --- /dev/null +++ b/tests/client/test_stdio.py @@ -0,0 +1,129 @@ +import inspect + +import pytest +from mcp.types import TextContent + +from fastmcp import Client +from fastmcp.client.transports import PythonStdioTransport, StdioTransport + + +class TestKeepAlive: + # https://github.com/jlowin/fastmcp/issues/581 + + @pytest.fixture + def stdio_script(self, tmp_path): + script = inspect.cleandoc(''' + import os + from fastmcp import FastMCP + + mcp = FastMCP() + + @mcp.tool() + def pid() -> int: + """Gets PID of server""" + return os.getpid() + + if __name__ == "__main__": + mcp.run() + ''') + script_file = tmp_path / "stdio.py" + script_file.write_text(script) + return script_file + + async def test_keep_alive_default_true(self): + client = Client(transport=StdioTransport(command="python", args=[""])) + + assert client.transport.keep_alive is True + + async def test_keep_alive_set_false(self): + client = Client( + transport=StdioTransport(command="python", args=[""], keep_alive=False) + ) + assert client.transport.keep_alive is False + + async def test_keep_alive_maintains_session_across_multiple_calls( + self, stdio_script + ): + client = Client(transport=PythonStdioTransport(script_path=stdio_script)) + assert client.transport.keep_alive is True + + async with client: + result1 = await client.call_tool("pid") + assert isinstance(result1[0], TextContent) + pid1 = int(result1[0].text) + + async with client: + result2 = await client.call_tool("pid") + assert isinstance(result2[0], TextContent) + pid2 = int(result2[0].text) + + assert pid1 == pid2 + + async def test_keep_alive_false_starts_new_session_across_multiple_calls( + self, stdio_script + ): + client = Client( + transport=PythonStdioTransport(script_path=stdio_script, keep_alive=False) + ) + assert client.transport.keep_alive is False + + async with client: + result1 = await client.call_tool("pid") + assert isinstance(result1[0], TextContent) + pid1 = int(result1[0].text) + + async with client: + result2 = await client.call_tool("pid") + assert isinstance(result2[0], TextContent) + pid2 = int(result2[0].text) + + assert pid1 != pid2 + + async def test_keep_alive_starts_new_session_if_manually_closed(self, stdio_script): + client = Client(transport=PythonStdioTransport(script_path=stdio_script)) + assert client.transport.keep_alive is True + + async with client: + result1 = await client.call_tool("pid") + assert isinstance(result1[0], TextContent) + pid1 = int(result1[0].text) + + await client.close() + + async with client: + result2 = await client.call_tool("pid") + assert isinstance(result2[0], TextContent) + pid2 = int(result2[0].text) + + assert pid1 != pid2 + + async def test_keep_alive_maintains_session_if_reentered(self, stdio_script): + client = Client(transport=PythonStdioTransport(script_path=stdio_script)) + assert client.transport.keep_alive is True + + async with client: + result1 = await client.call_tool("pid") + assert isinstance(result1[0], TextContent) + pid1 = int(result1[0].text) + + async with client: + result2 = await client.call_tool("pid") + assert isinstance(result2[0], TextContent) + pid2 = int(result2[0].text) + + result3 = await client.call_tool("pid") + assert isinstance(result3[0], TextContent) + pid3 = int(result3[0].text) + + assert pid1 == pid2 == pid3 + + async def test_close_session_and_try_to_use_client_raises_error(self, stdio_script): + client = Client(transport=PythonStdioTransport(script_path=stdio_script)) + assert client.transport.keep_alive is True + + with pytest.raises( + RuntimeError, match="Server session was closed unexpectedly" + ): + async with client: + await client.close() + await client.call_tool("pid") From 97bccb9104746f0db1019d24ecb9235bad09321d Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 28 May 2025 21:58:43 -0400 Subject: [PATCH 111/114] Update client.mdx --- docs/clients/client.mdx | 45 ++++++----------------------------------- 1 file changed, 6 insertions(+), 39 deletions(-) diff --git a/docs/clients/client.mdx b/docs/clients/client.mdx index 74ee9e3b4..504993fa4 100644 --- a/docs/clients/client.mdx +++ b/docs/clients/client.mdx @@ -286,55 +286,22 @@ When using stdio transports, clients support a `keep_alive` feature (enabled by When `keep_alive=False`, the client will automatically close the session when the context manager exits. - -```python keep_alive=True +```python from fastmcp import Client -# Client with keep_alive=True (default) -client = Client("my_mcp_server.py") +client = Client("my_mcp_server.py") # keep_alive=True by default async def example(): - # First session - async with client: - await client.ping() - - # Second session - uses the same subprocess - async with client: - await client.ping() - - # Manually close the session - await client.close() - - # Third session - will start a new subprocess - async with client: - await client.ping() - -asyncio.run(example()) -``` -```python keep_alive=False -from fastmcp import Client - -# Client with keep_alive=False -client = Client("my_mcp_server.py", keep_alive=False) - -async def example(): - # First session async with client: await client.ping() - # Second session - will start a new subprocess async with client: - await client.ping() - - # Third session - will start a new subprocess - async with client: - await client.ping() - -asyncio.run(example()) + await client.ping() # Same subprocess as above ``` - - + +For detailed examples and configuration options, see [Session Management in Transports](/clients/transports#session-management). + #### Timeouts From 441223f98cb8ea01b2c40d89ce0e2555ff58c2cc Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 28 May 2025 22:01:00 -0400 Subject: [PATCH 112/114] Update docstrings --- src/fastmcp/client/transports.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 1fa52bf1a..b1938e10c 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -101,6 +101,7 @@ class WSTransport(ClientTransport): warnings.warn( "WSTransport is a deprecated MCP transport and will be removed in a future version. Use StreamableHttpTransport instead.", DeprecationWarning, + stacklevel=2, ) if isinstance(url, AnyUrl): url = str(url) @@ -248,6 +249,10 @@ class StdioTransport(ClientTransport): args: The arguments to pass to the command env: Environment variables to set for the subprocess cwd: Current working directory for the subprocess + keep_alive: Whether to keep the subprocess alive between connections. + Defaults to True. When True, the subprocess remains active + after the connection context exits, allowing reuse in + subsequent connections. """ self.command = command self.args = args @@ -356,6 +361,10 @@ class PythonStdioTransport(StdioTransport): env: Environment variables to set for the subprocess cwd: Current working directory for the subprocess python_cmd: Python command to use (default: "python") + keep_alive: Whether to keep the subprocess alive between connections. + Defaults to True. When True, the subprocess remains active + after the connection context exits, allowing reuse in + subsequent connections. """ script_path = Path(script_path).resolve() if not script_path.is_file(): @@ -425,6 +434,10 @@ class NodeStdioTransport(StdioTransport): env: Environment variables to set for the subprocess cwd: Current working directory for the subprocess node_cmd: Node.js command to use (default: "node") + keep_alive: Whether to keep the subprocess alive between connections. + Defaults to True. When True, the subprocess remains active + after the connection context exits, allowing reuse in + subsequent connections. """ script_path = Path(script_path).resolve() if not script_path.is_file(): @@ -467,6 +480,10 @@ class UvxStdioTransport(StdioTransport): with_packages: Additional packages to include from_package: Package to install the tool from env_vars: Additional environment variables + keep_alive: Whether to keep the subprocess alive between connections. + Defaults to True. When True, the subprocess remains active + after the connection context exits, allowing reuse in + subsequent connections. """ # Basic validation if project_directory and not Path(project_directory).exists(): @@ -525,6 +542,10 @@ class NpxStdioTransport(StdioTransport): project_directory: Project directory with package.json env_vars: Additional environment variables use_package_lock: Whether to use package-lock.json (--prefer-offline) + keep_alive: Whether to keep the subprocess alive between connections. + Defaults to True. When True, the subprocess remains active + after the connection context exits, allowing reuse in + subsequent connections. """ # verify npx is installed if shutil.which("npx") is None: From 975f151a9bfc5e701219c991d4fe907c6a9bb770 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 28 May 2025 22:02:03 -0400 Subject: [PATCH 113/114] Reset session after closing --- src/fastmcp/client/client.py | 2 ++ tests/client/test_stdio.py | 8 +++----- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 1fee12ff7..03fb7fd5a 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -268,6 +268,8 @@ class Client(Generic[ClientTransportT]): async def close(self): await self.transport.close() + self._session = None + self._initialize_result = None # --- MCP Client Methods --- diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py index 55d430f7c..71bbefe4a 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -121,9 +121,7 @@ class TestKeepAlive: client = Client(transport=PythonStdioTransport(script_path=stdio_script)) assert client.transport.keep_alive is True - with pytest.raises( - RuntimeError, match="Server session was closed unexpectedly" - ): - async with client: - await client.close() + async with client: + await client.close() + with pytest.raises(RuntimeError, match="Client is not connected"): await client.call_tool("pid") From 78610a27919445557bd48b8bcac1edd8fa42e3f1 Mon Sep 17 00:00:00 2001 From: Kyoji Ogasawara Date: Thu, 29 May 2025 11:17:22 +0900 Subject: [PATCH 114/114] Support for uppercase letters at the log level Previously, fastmcp commands using uppercase log levels would fail to execute. This update adds support for uppercase letters in log level specifications. e.g. fastmcp run --transport streamable-http --host 0.0.0.0 -p 8000 --log-level DEBUG examples/echo.py --- src/fastmcp/server/server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 89d760370..5666c2a64 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -802,7 +802,7 @@ class FastMCP(Generic[LifespanResultT]): """ host = host or self.settings.host port = port or self.settings.port - default_log_level_to_use = log_level or self.settings.log_level.lower() + default_log_level_to_use = (log_level or self.settings.log_level).lower() app = self.http_app(path=path, transport=transport, middleware=middleware)