From 53824c928907cea14c58b995f9a9ddcf87f731d2 Mon Sep 17 00:00:00 2001 From: dougiehauser Date: Wed, 16 Apr 2025 00:37:10 +0300 Subject: [PATCH 1/6] limit tool name length --- fastapi_mcp/openapi/convert.py | 4 ++-- fastapi_mcp/server.py | 28 +++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/fastapi_mcp/openapi/convert.py b/fastapi_mcp/openapi/convert.py index 22e5c5e..4f3f81c 100644 --- a/fastapi_mcp/openapi/convert.py +++ b/fastapi_mcp/openapi/convert.py @@ -43,13 +43,13 @@ def convert_openapi_to_mcp_tools( for method, operation in path_item.items(): # Skip non-HTTP methods if method not in ["get", "post", "put", "delete", "patch"]: - logger.warning(f"Skipping non-HTTP method: {method}") + logger.warning(f"Skipping non-HTTP method: {method.upper()} {path}") continue # Get operation metadata operation_id = operation.get("operationId") if not operation_id: - logger.warning(f"Skipping operation with no operationId: {operation}") + logger.warning(f"Skipping operation with no operationId: {method.upper()} {path}, details: {operation}") continue # Save operation details for later HTTP calls diff --git a/fastapi_mcp/server.py b/fastapi_mcp/server.py index f5c4fc6..0be89c6 100644 --- a/fastapi_mcp/server.py +++ b/fastapi_mcp/server.py @@ -17,6 +17,8 @@ logger = logging.getLogger(__name__) +FULL_TOOL_NAME_MAX_LENGTH = 55 + class LowlevelMCPServer(Server): def call_tool(self): @@ -489,13 +491,34 @@ def _filter_tools(self, tools: List[types.Tool], openapi_schema: Dict[str, Any]) operations_by_tag: Dict[str, List[str]] = {} for path, path_item in openapi_schema.get("paths", {}).items(): for method, operation in path_item.items(): + operation_id = operation.get("operationId") if method not in ["get", "post", "put", "delete", "patch"]: + logger.warning(f"Skipping non-HTTP method: {method.upper()} {path}, operation_id: {operation_id}") continue - operation_id = operation.get("operationId") if not operation_id: + logger.warning( + f"Skipping operation with no operationId: {method.upper()} {path}, details: {operation}" + ) + continue + + operation_full_name = self.get_tool_full_name(operation_id) + if len(operation_full_name) > FULL_TOOL_NAME_MAX_LENGTH: + logger.warning(f"Skipping operation with exceedingly long operationId: {operation_full_name}") continue + """ + if method not in ["get", "post", "put", "delete", "patch"]: + logger.warning(f"Skipping non-HTTP method: {method.upper()} {path}") + continue + + # Get operation metadata + operation_id = operation.get("operationId") + if not operation_id: + logger.warning(f"Skipping operation with no operationId: {method.upper()} {path}, details: {operation}") + continue + """ + tags = operation.get("tags", []) for tag in tags: if tag not in operations_by_tag: @@ -530,3 +553,6 @@ def _filter_tools(self, tools: List[types.Tool], openapi_schema: Dict[str, Any]) } return filtered_tools + + def get_tool_full_name(self, operation_id: str) -> str: + return f"{self.name}\\{operation_id}" From 41d5d607e37151af4692d954785775c9aac7dd72 Mon Sep 17 00:00:00 2001 From: dougiehauser Date: Sun, 20 Apr 2025 01:17:33 +0300 Subject: [PATCH 2/6] changes followig CR --- fastapi_mcp/server.py | 51 +++++++++++++++++++++---------------- tests/test_configuration.py | 28 ++++++++++++++++---- 2 files changed, 52 insertions(+), 27 deletions(-) diff --git a/fastapi_mcp/server.py b/fastapi_mcp/server.py index 0be89c6..109a310 100644 --- a/fastapi_mcp/server.py +++ b/fastapi_mcp/server.py @@ -17,8 +17,6 @@ logger = logging.getLogger(__name__) -FULL_TOOL_NAME_MAX_LENGTH = 55 - class LowlevelMCPServer(Server): def call_tool(self): @@ -111,6 +109,10 @@ def __init__( Optional[List[str]], Doc("List of tags to exclude from MCP tools. Cannot be used with include_tags."), ] = None, + max_tool_name_length: Annotated[ + Optional[int], + Doc("Maximum length allowed for tools (some vendors prohibit long names).") + ] = None, auth_config: Annotated[ Optional[AuthConfig], Doc("Configuration for MCP authentication"), @@ -138,6 +140,7 @@ def __init__( self._exclude_operations = exclude_operations self._include_tags = include_tags self._exclude_tags = exclude_tags + self._max_tool_name_length = max_tool_name_length self._auth_config = auth_config if self._auth_config: @@ -485,6 +488,7 @@ def _filter_tools(self, tools: List[types.Tool], openapi_schema: Dict[str, Any]) and self._exclude_operations is None and self._include_tags is None and self._exclude_tags is None + and self._max_tool_name_length is None ): return tools @@ -502,23 +506,6 @@ def _filter_tools(self, tools: List[types.Tool], openapi_schema: Dict[str, Any]) ) continue - operation_full_name = self.get_tool_full_name(operation_id) - if len(operation_full_name) > FULL_TOOL_NAME_MAX_LENGTH: - logger.warning(f"Skipping operation with exceedingly long operationId: {operation_full_name}") - continue - - """ - if method not in ["get", "post", "put", "delete", "patch"]: - logger.warning(f"Skipping non-HTTP method: {method.upper()} {path}") - continue - - # Get operation metadata - operation_id = operation.get("operationId") - if not operation_id: - logger.warning(f"Skipping operation with no operationId: {method.upper()} {path}, details: {operation}") - continue - """ - tags = operation.get("tags", []) for tag in tags: if tag not in operations_by_tag: @@ -527,11 +514,14 @@ def _filter_tools(self, tools: List[types.Tool], openapi_schema: Dict[str, Any]) operations_to_include = set() + all_operations = {tool.name for tool in tools} + if self._include_operations is not None: operations_to_include.update(self._include_operations) elif self._exclude_operations is not None: - all_operations = {tool.name for tool in tools} operations_to_include.update(all_operations - set(self._exclude_operations)) + elif self._max_tool_name_length is not None: + operations_to_include.update(all_operations) # all_operations if self._include_tags is not None: for tag in self._include_tags: @@ -541,9 +531,14 @@ def _filter_tools(self, tools: List[types.Tool], openapi_schema: Dict[str, Any]) for tag in self._exclude_tags: excluded_operations.update(operations_by_tag.get(tag, [])) - all_operations = {tool.name for tool in tools} operations_to_include.update(all_operations - excluded_operations) + if self._max_tool_name_length is not None: + long_operations = { + tool.name for tool in tools if len(self.get_combined_full_name(tool.name)) > self._max_tool_name_length + } + operations_to_include = operations_to_include - long_operations + filtered_tools = [tool for tool in tools if tool.name in operations_to_include] if filtered_tools: @@ -554,5 +549,17 @@ def _filter_tools(self, tools: List[types.Tool], openapi_schema: Dict[str, Any]) return filtered_tools - def get_tool_full_name(self, operation_id: str) -> str: + def get_combined_full_name(self, operation_id: str) -> str: + """ + Combined name consists of server name + operation_id + + Args: + operation_id: As defined during creation + + Returns: + concatenated string of server name + operation_id + """ + if not self.name: + return operation_id + return f"{self.name}\\{operation_id}" diff --git a/tests/test_configuration.py b/tests/test_configuration.py index 79e403e..41a99ba 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -17,6 +17,8 @@ def test_default_configuration(simple_fastapi_app: FastAPI): assert mcp_server._describe_all_responses is False assert mcp_server._describe_full_response_schema is False + assert mcp_server._max_tool_name_length is None + def test_custom_configuration(simple_fastapi_app: FastAPI): """Test a custom configuration of FastApiMCP.""" @@ -372,13 +374,21 @@ async def delete_item(item_id: int): async def search_items(): return [{"id": 1}] + @app.get("/long_search/", operation_id="search_items_long_id", tags=["search"]) + async def search_items_long(): + return [{"id": 1}] + + # benchmark - no filter + no_filter = FastApiMCP(app) + assert len(no_filter.tools) == 7 + # Test include_operations include_ops_mcp = FastApiMCP(app, include_operations=["get_item", "list_items"]) assert len(include_ops_mcp.tools) == 2 assert {tool.name for tool in include_ops_mcp.tools} == {"get_item", "list_items"} # Test exclude_operations - exclude_ops_mcp = FastApiMCP(app, exclude_operations=["delete_item", "search_items"]) + exclude_ops_mcp = FastApiMCP(app, exclude_operations=["delete_item", "search_items", "search_items_long_id"]) assert len(exclude_ops_mcp.tools) == 4 assert {tool.name for tool in exclude_ops_mcp.tools} == {"get_item", "list_items", "create_item", "update_item"} @@ -389,13 +399,21 @@ async def search_items(): # Test exclude_tags exclude_tags_mcp = FastApiMCP(app, exclude_tags=["write", "delete"]) - assert len(exclude_tags_mcp.tools) == 3 - assert {tool.name for tool in exclude_tags_mcp.tools} == {"get_item", "list_items", "search_items"} + assert len(exclude_tags_mcp.tools) == 4 + assert {tool.name for tool in exclude_tags_mcp.tools} == { + "get_item", + "list_items", + "search_items", + "search_items_long_id", + } # Test combining include_operations and include_tags combined_include_mcp = FastApiMCP(app, include_operations=["delete_item"], include_tags=["search"]) - assert len(combined_include_mcp.tools) == 2 - assert {tool.name for tool in combined_include_mcp.tools} == {"delete_item", "search_items"} + assert len(combined_include_mcp.tools) == 3 + assert {tool.name for tool in combined_include_mcp.tools} == {"delete_item", "search_items", "search_items_long_id"} + + max_long_name_mcp = FastApiMCP(app, name="mcp_server", max_tool_name_length=25) + assert len(max_long_name_mcp.tools) == 6 # Test invalid combinations with pytest.raises(ValueError): From 56c01784dc729a6ecd50eb030b4b640bfff19d3e Mon Sep 17 00:00:00 2001 From: dougiehauser Date: Sun, 20 Apr 2025 15:28:32 +0300 Subject: [PATCH 3/6] comment tweak --- tests/test_configuration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_configuration.py b/tests/test_configuration.py index 41a99ba..5ac36ed 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -378,7 +378,7 @@ async def search_items(): async def search_items_long(): return [{"id": 1}] - # benchmark - no filter + # Benchmark - no filter no_filter = FastApiMCP(app) assert len(no_filter.tools) == 7 From 985a9df803f6bd66395354f7c70ad59faf145d42 Mon Sep 17 00:00:00 2001 From: dougiehauser Date: Mon, 21 Apr 2025 15:35:12 +0300 Subject: [PATCH 4/6] logic fix to handle include/exclude conditions with len limit --- fastapi_mcp/server.py | 22 +++++++++++++--------- tests/test_configuration.py | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/fastapi_mcp/server.py b/fastapi_mcp/server.py index 109a310..1ceb85f 100644 --- a/fastapi_mcp/server.py +++ b/fastapi_mcp/server.py @@ -483,13 +483,14 @@ def _filter_tools(self, tools: List[types.Tool], openapi_schema: Dict[str, Any]) Returns: Filtered list of tools """ - if ( - self._include_operations is None - and self._exclude_operations is None - and self._include_tags is None - and self._exclude_tags is None - and self._max_tool_name_length is None - ): + include_exclude_conditions_exist = ( + self._include_operations is not None + or self._exclude_operations is not None + or self._include_tags is not None + or self._exclude_tags is not None + ) + + if not include_exclude_conditions_exist and self._max_tool_name_length is None: return tools operations_by_tag: Dict[str, List[str]] = {} @@ -520,8 +521,6 @@ def _filter_tools(self, tools: List[types.Tool], openapi_schema: Dict[str, Any]) operations_to_include.update(self._include_operations) elif self._exclude_operations is not None: operations_to_include.update(all_operations - set(self._exclude_operations)) - elif self._max_tool_name_length is not None: - operations_to_include.update(all_operations) # all_operations if self._include_tags is not None: for tag in self._include_tags: @@ -533,6 +532,11 @@ def _filter_tools(self, tools: List[types.Tool], openapi_schema: Dict[str, Any]) operations_to_include.update(all_operations - excluded_operations) + # This condition means that no include/exclude conditions exist, + # and we've reached this point because we have a max-length limit + if not include_exclude_conditions_exist: + operations_to_include = all_operations + if self._max_tool_name_length is not None: long_operations = { tool.name for tool in tools if len(self.get_combined_full_name(tool.name)) > self._max_tool_name_length diff --git a/tests/test_configuration.py b/tests/test_configuration.py index 5ac36ed..23336dc 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -414,6 +414,22 @@ async def search_items_long(): max_long_name_mcp = FastApiMCP(app, name="mcp_server", max_tool_name_length=25) assert len(max_long_name_mcp.tools) == 6 + assert {tool.name for tool in max_long_name_mcp.tools} == { + "list_items", + "get_item", + "create_item", + "update_item", + "delete_item", + "search_items", + } + + combined_exclude_and_max_tags_mcp = FastApiMCP(app, exclude_tags=["write", "delete"], max_tool_name_length=25) + assert len(combined_exclude_and_max_tags_mcp.tools) == 3 + assert {tool.name for tool in combined_exclude_and_max_tags_mcp.tools} == { + "get_item", + "list_items", + "search_items", + } # Test invalid combinations with pytest.raises(ValueError): From 03c35b3e3d679c74d9472c6c2c18eb17244ebdf5 Mon Sep 17 00:00:00 2001 From: dougiehauser Date: Wed, 23 Apr 2025 18:19:41 +0300 Subject: [PATCH 5/6] adjust change to new mintlify docs --- docs/configurations/customization.mdx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/configurations/customization.mdx b/docs/configurations/customization.mdx index 2e8e3ac..ce519c3 100644 --- a/docs/configurations/customization.mdx +++ b/docs/configurations/customization.mdx @@ -50,10 +50,11 @@ You can control which FastAPI endpoints are exposed as MCP tools using Open API - Only include operations with specific tags - Exclude operations with specific tags - Combine operation IDs and tags +- Filter operation IDs that exceed a maximum allowed length ### Code samples -The relevant arguments for these configurations are `include_operations`, `exclude_operations`, `include_tags`, `exclude_tags` and can be used as follows: +The relevant arguments for these configurations are `include_operations`, `exclude_operations`, `include_tags`, `exclude_tags`, `max_tool_name_length` and can be used as follows: ```python Include Operations {8} @@ -121,6 +122,19 @@ The relevant arguments for these configurations are `include_operations`, `exclu ) mcp.mount() ``` + + ```python Max Length {8} + from fastapi import FastAPI + from fastapi_mcp import FastApiMCP + + app = FastAPI() + + mcp = FastApiMCP( + app, + max_tool_name_length=25, + ) + mcp.mount() + ``` ### Notes on filtering @@ -128,4 +142,5 @@ The relevant arguments for these configurations are `include_operations`, `exclu - You cannot use both `include_operations` and `exclude_operations` at the same time - You cannot use both `include_tags` and `exclude_tags` at the same time - You can combine operation filtering with tag filtering (e.g., use `include_operations` with `include_tags`) -- When combining filters, a greedy approach will be taken. Endpoints matching either criteria will be included \ No newline at end of file +- When combining filters, a greedy approach will be taken. Endpoints matching either criteria will be included +- Max length filtering can combined with any other filter \ No newline at end of file From 577ffa3d8a9d879aba1fb9f4bed1434a57487303 Mon Sep 17 00:00:00 2001 From: dougiehauser Date: Tue, 20 May 2025 11:57:32 +0300 Subject: [PATCH 6/6] adding log warning indicating max limit filtering --- fastapi_mcp/server.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/fastapi_mcp/server.py b/fastapi_mcp/server.py index 1ceb85f..ccaadf9 100644 --- a/fastapi_mcp/server.py +++ b/fastapi_mcp/server.py @@ -110,8 +110,7 @@ def __init__( Doc("List of tags to exclude from MCP tools. Cannot be used with include_tags."), ] = None, max_tool_name_length: Annotated[ - Optional[int], - Doc("Maximum length allowed for tools (some vendors prohibit long names).") + Optional[int], Doc("Maximum length allowed for tools (some vendors prohibit long names).") ] = None, auth_config: Annotated[ Optional[AuthConfig], @@ -541,6 +540,12 @@ def _filter_tools(self, tools: List[types.Tool], openapi_schema: Dict[str, Any]) long_operations = { tool.name for tool in tools if len(self.get_combined_full_name(tool.name)) > self._max_tool_name_length } + + if long_operations: + logger.warning( + f"Some operations exceed allowed max tool name length of {str(self._max_tool_name_length)} characters: {long_operations}" + ) + operations_to_include = operations_to_include - long_operations filtered_tools = [tool for tool in tools if tool.name in operations_to_include]