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 01/18] 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 02/18] 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 03/18] 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 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 04/18] 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 05/18] 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 06/18] 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 07/18] 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 08/18] 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 09/18] 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 10/18] 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 11/18] 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 12/18] 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 13/18] 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 14/18] 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 15/18] 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 16/18] 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 17/18] 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 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 18/18] 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.",