From fb7c2c8d6935e36b0768acb10027d56448797e71 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 1 May 2025 09:42:05 -0400 Subject: [PATCH] Ensure openapi descriptions are included in tool details --- src/fastmcp/server/openapi.py | 26 ++- src/fastmcp/utilities/openapi.py | 118 ++++++++---- tests/server/test_openapi.py | 316 +++++++++++++++++++++++++++++++ 3 files changed, 418 insertions(+), 42 deletions(-) diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py index fd483e399..86bcc290e 100644 --- a/src/fastmcp/server/openapi.py +++ b/src/fastmcp/server/openapi.py @@ -534,10 +534,12 @@ class FastMCPOpenAPI(FastMCP): or f"Executes {route.method} {route.path}" ) - # Format enhanced description + # Format enhanced description with parameters and request body enhanced_description = format_description_with_responses( base_description=base_description, responses=route.responses, + parameters=route.parameters, + request_body=route.request_body, ) tool = OpenAPITool( @@ -565,10 +567,12 @@ class FastMCPOpenAPI(FastMCP): route.description or route.summary or f"Represents {route.path}" ) - # Format enhanced description + # Format enhanced description with parameters and request body enhanced_description = format_description_with_responses( base_description=base_description, responses=route.responses, + parameters=route.parameters, + request_body=route.request_body, ) resource = OpenAPIResource( @@ -600,16 +604,30 @@ class FastMCPOpenAPI(FastMCP): route.description or route.summary or f"Template for {route.path}" ) - # Format enhanced description + # Format enhanced description with parameters and request body enhanced_description = format_description_with_responses( base_description=base_description, responses=route.responses, + parameters=route.parameters, + request_body=route.request_body, ) template_params_schema = { "type": "object", "properties": { - p.name: p.schema_ for p in route.parameters if p.location == "path" + p.name: { + **(p.schema_.copy() if isinstance(p.schema_, dict) else {}), + **( + {"description": p.description} + if p.description + and not ( + isinstance(p.schema_, dict) and "description" in p.schema_ + ) + else {} + ), + } + for p in route.parameters + if p.location == "path" }, "required": [ p.name for p in route.parameters if p.location == "path" and p.required diff --git a/src/fastmcp/utilities/openapi.py b/src/fastmcp/utilities/openapi.py index 5cf50cf06..060f97929 100644 --- a/src/fastmcp/utilities/openapi.py +++ b/src/fastmcp/utilities/openapi.py @@ -1001,53 +1001,84 @@ def format_description_with_responses( responses: dict[ str, Any ], # Changed from specific ResponseInfo type to avoid circular imports + parameters: list[openapi.ParameterInfo] | None = None, # Add parameters parameter + request_body: openapi.RequestBodyInfo | None = None, # Add request_body parameter ) -> str: - """Formats the base description string with response information.""" - if not responses: - return base_description - + """Formats the base description string with response and parameter information.""" desc_parts = [base_description] - response_section = "\n\n**Responses:**" - added_response_section = False - # Determine success codes (common ones) - success_codes = {"200", "201", "202", "204"} # As strings - success_status = next((s for s in success_codes if s in responses), None) + # Add parameter information + if parameters: + # Process path parameters + path_params = [p for p in parameters if p.location == "path"] + if path_params: + param_section = "\n\n**Path Parameters:**" + desc_parts.append(param_section) + for param in path_params: + required_marker = " (Required)" if param.required else "" + param_desc = f"\n- **{param.name}**{required_marker}: {param.description or 'No description.'}" + desc_parts.append(param_desc) - # Process all responses - responses_to_process = responses.items() + # Process query parameters + query_params = [p for p in parameters if p.location == "query"] + if query_params: + param_section = "\n\n**Query Parameters:**" + desc_parts.append(param_section) + for param in query_params: + required_marker = " (Required)" if param.required else "" + param_desc = f"\n- **{param.name}**{required_marker}: {param.description or 'No description.'}" + desc_parts.append(param_desc) - for status_code, resp_info in sorted(responses_to_process): - if not added_response_section: - desc_parts.append(response_section) - added_response_section = True + # Add request body information if present + if request_body and request_body.description: + req_body_section = "\n\n**Request Body:**" + desc_parts.append(req_body_section) + required_marker = " (Required)" if request_body.required else "" + desc_parts.append(f"\n{request_body.description}{required_marker}") - status_marker = " (Success)" if status_code == success_status else "" - desc_parts.append( - f"\n- **{status_code}**{status_marker}: {resp_info.description or 'No description.'}" - ) + # Add response information + if responses: + response_section = "\n\n**Responses:**" + added_response_section = False - # Process content schemas for this response - if resp_info.content_schema: - # Prioritize json, then take first available - media_type = ( - "application/json" - if "application/json" in resp_info.content_schema - else next(iter(resp_info.content_schema), None) + # Determine success codes (common ones) + success_codes = {"200", "201", "202", "204"} # As strings + success_status = next((s for s in success_codes if s in responses), None) + + # Process all responses + responses_to_process = responses.items() + + for status_code, resp_info in sorted(responses_to_process): + if not added_response_section: + desc_parts.append(response_section) + added_response_section = True + + status_marker = " (Success)" if status_code == success_status else "" + desc_parts.append( + f"\n- **{status_code}**{status_marker}: {resp_info.description or 'No description.'}" ) - if media_type: - schema = resp_info.content_schema.get(media_type) - desc_parts.append(f" - Content-Type: `{media_type}`") + # Process content schemas for this response + if resp_info.content_schema: + # Prioritize json, then take first available + media_type = ( + "application/json" + if "application/json" in resp_info.content_schema + else next(iter(resp_info.content_schema), None) + ) - if schema: - # Generate Example - example = generate_example_from_schema(schema) - if example != "unknown_type" and example is not None: - desc_parts.append("\n - **Example:**") - desc_parts.append( - format_json_for_description(example, indent=2) - ) + if media_type: + schema = resp_info.content_schema.get(media_type) + desc_parts.append(f" - Content-Type: `{media_type}`") + + if schema: + # Generate Example + example = generate_example_from_schema(schema) + if example != "unknown_type" and example is not None: + desc_parts.append("\n - **Example:**") + desc_parts.append( + format_json_for_description(example, indent=2) + ) return "\n".join(desc_parts) @@ -1069,7 +1100,15 @@ def _combine_schemas(route: openapi.HTTPRoute) -> dict[str, Any]: for param in route.parameters: if param.required: required.append(param.name) - properties[param.name] = param.schema_ + + # Copy the schema and add description if available + param_schema = param.schema_.copy() if isinstance(param.schema_, dict) else {} + + # Add parameter description to schema if available and not already present + if param.description and not param_schema.get("description"): + param_schema["description"] = param.description + + properties[param.name] = param_schema # Add request body if it exists if route.request_body and route.request_body.content_schema: @@ -1077,8 +1116,11 @@ def _combine_schemas(route: openapi.HTTPRoute) -> dict[str, Any]: content_type = next(iter(route.request_body.content_schema)) body_schema = route.request_body.content_schema[content_type] body_props = body_schema.get("properties", {}) + + # Add request body properties for prop_name, prop_schema in body_props.items(): properties[prop_name] = prop_schema + if route.request_body.required: required.extend(body_schema.get("required", [])) diff --git a/tests/server/test_openapi.py b/tests/server/test_openapi.py index 430e4f2ad..77c98bf32 100644 --- a/tests/server/test_openapi.py +++ b/tests/server/test_openapi.py @@ -1034,3 +1034,319 @@ async def test_none_path_parameters_rejected( "name": "New Name", }, ) + + +class TestDescriptionPropagation: + """Tests for OpenAPI description propagation to FastMCP components. + + Each test focuses on a single, specific behavior to make it immediately clear + what's broken when a test fails. + """ + + @pytest.fixture + def simple_openapi_spec(self) -> dict: + """Create a minimal OpenAPI spec with obvious test descriptions.""" + return { + "openapi": "3.1.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "paths": { + "/items": { + "get": { + "operationId": "listItems", + "summary": "List items summary", + "description": "LIST_DESCRIPTION", + "responses": { + "200": {"description": "LIST_RESPONSE_DESCRIPTION"} + }, + } + }, + "/items/{item_id}": { + "get": { + "operationId": "getItem", + "summary": "Get item summary", + "description": "GET_DESCRIPTION", + "parameters": [ + { + "name": "item_id", + "in": "path", + "required": True, + "description": "PATH_PARAM_DESCRIPTION", + "schema": {"type": "string"}, + }, + { + "name": "fields", + "in": "query", + "required": False, + "description": "QUERY_PARAM_DESCRIPTION", + "schema": {"type": "string"}, + }, + ], + "responses": { + "200": {"description": "GET_RESPONSE_DESCRIPTION"} + }, + } + }, + "/items/create": { + "post": { + "operationId": "createItem", + "summary": "Create item summary", + "description": "CREATE_DESCRIPTION", + "requestBody": { + "required": True, + "description": "BODY_DESCRIPTION", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "PROP_DESCRIPTION", + } + }, + "required": ["name"], + } + } + }, + }, + "responses": { + "201": {"description": "CREATE_RESPONSE_DESCRIPTION"} + }, + } + }, + }, + } + + @pytest.fixture + async def mock_client(self) -> httpx.AsyncClient: + """Create a mock client that returns simple responses.""" + + async def _responder(request): + if request.url.path == "/items" and request.method == "GET": + return httpx.Response(200, json=[{"id": "1", "name": "Item 1"}]) + elif request.url.path.startswith("/items/") and request.method == "GET": + item_id = request.url.path.split("/")[-1] + return httpx.Response( + 200, json={"id": item_id, "name": f"Item {item_id}"} + ) + elif request.url.path == "/items/create" and request.method == "POST": + import json + + data = json.loads(request.content) + return httpx.Response(201, json={"id": "new", "name": data.get("name")}) + + return httpx.Response(404) + + transport = httpx.MockTransport(_responder) + return httpx.AsyncClient(transport=transport, base_url="http://test") + + @pytest.fixture + async def test_server(self, simple_openapi_spec, mock_client): + """Create a FastMCPOpenAPI server with the simple test spec.""" + return FastMCPOpenAPI( + openapi_spec=simple_openapi_spec, + client=mock_client, + name="Test API", + ) + + # --- RESOURCE TESTS --- + + async def test_resource_includes_route_description(self, test_server): + """Test that a Resource includes the route description.""" + resources = list(test_server._resource_manager.get_resources().values()) + list_resource = next((r for r in resources if r.name == "listItems"), None) + + assert list_resource is not None, "listItems resource wasn't created" + assert "LIST_DESCRIPTION" in (list_resource.description or ""), ( + "Route description missing from Resource" + ) + + async def test_resource_includes_response_description(self, test_server): + """Test that a Resource includes the response description.""" + resources = list(test_server._resource_manager.get_resources().values()) + list_resource = next((r for r in resources if r.name == "listItems"), None) + + assert list_resource is not None, "listItems resource wasn't created" + assert "LIST_RESPONSE_DESCRIPTION" in (list_resource.description or ""), ( + "Response description missing from Resource" + ) + + # --- RESOURCE TEMPLATE TESTS --- + + async def test_template_includes_route_description(self, test_server): + """Test that a ResourceTemplate includes the route description.""" + templates = list(test_server._resource_manager.get_templates().values()) + get_template = next((t for t in templates if t.name == "getItem"), None) + + assert get_template is not None, "getItem template wasn't created" + assert "GET_DESCRIPTION" in (get_template.description or ""), ( + "Route description missing from ResourceTemplate" + ) + + async def test_template_includes_path_parameter_description(self, test_server): + """Test that a ResourceTemplate includes path parameter descriptions.""" + templates = list(test_server._resource_manager.get_templates().values()) + get_template = next((t for t in templates if t.name == "getItem"), None) + + assert get_template is not None, "getItem template wasn't created" + assert "PATH_PARAM_DESCRIPTION" in (get_template.description or ""), ( + "Path parameter description missing from ResourceTemplate description" + ) + + async def test_template_includes_query_parameter_description(self, test_server): + """Test that a ResourceTemplate includes query parameter descriptions.""" + templates = list(test_server._resource_manager.get_templates().values()) + get_template = next((t for t in templates if t.name == "getItem"), None) + + assert get_template is not None, "getItem template wasn't created" + assert "QUERY_PARAM_DESCRIPTION" in (get_template.description or ""), ( + "Query parameter description missing from ResourceTemplate description" + ) + + async def test_template_includes_response_description(self, test_server): + """Test that a ResourceTemplate includes response descriptions.""" + templates = list(test_server._resource_manager.get_templates().values()) + get_template = next((t for t in templates if t.name == "getItem"), None) + + assert get_template is not None, "getItem template wasn't created" + assert "GET_RESPONSE_DESCRIPTION" in (get_template.description or ""), ( + "Response description missing from ResourceTemplate description" + ) + + async def test_template_parameter_schema_includes_description(self, test_server): + """Test that a ResourceTemplate's parameter schema includes parameter descriptions.""" + templates = list(test_server._resource_manager.get_templates().values()) + get_template = next((t for t in templates if t.name == "getItem"), None) + + assert get_template is not None, "getItem template wasn't created" + assert "properties" in get_template.parameters, ( + "Schema properties missing from ResourceTemplate" + ) + assert "item_id" in get_template.parameters["properties"], ( + "item_id missing from ResourceTemplate schema" + ) + assert "description" in get_template.parameters["properties"]["item_id"], ( + "Description missing from item_id parameter schema" + ) + assert ( + "PATH_PARAM_DESCRIPTION" + in get_template.parameters["properties"]["item_id"]["description"] + ), "Path parameter description incorrect in schema" + + # --- TOOL TESTS --- + + async def test_tool_includes_route_description(self, test_server): + """Test that a Tool includes the route description.""" + tools = test_server._tool_manager.list_tools() + create_tool = next((t for t in tools if t.name == "createItem"), None) + + assert create_tool is not None, "createItem tool wasn't created" + assert "CREATE_DESCRIPTION" in (create_tool.description or ""), ( + "Route description missing from Tool" + ) + + async def test_tool_includes_request_body_description(self, test_server): + """Test that a Tool includes the request body description.""" + tools = test_server._tool_manager.list_tools() + create_tool = next((t for t in tools if t.name == "createItem"), None) + + assert create_tool is not None, "createItem tool wasn't created" + assert "BODY_DESCRIPTION" in (create_tool.description or ""), ( + "Request body description missing from Tool" + ) + + async def test_tool_includes_response_description(self, test_server): + """Test that a Tool includes response descriptions.""" + tools = test_server._tool_manager.list_tools() + create_tool = next((t for t in tools if t.name == "createItem"), None) + + assert create_tool is not None, "createItem tool wasn't created" + assert "CREATE_RESPONSE_DESCRIPTION" in (create_tool.description or ""), ( + "Response description missing from Tool" + ) + + async def test_tool_parameter_schema_includes_property_description( + self, test_server + ): + """Test that a Tool's parameter schema includes property descriptions.""" + tools = test_server._tool_manager.list_tools() + create_tool = next((t for t in tools if t.name == "createItem"), None) + + assert create_tool is not None, "createItem tool wasn't created" + assert "properties" in create_tool.parameters, ( + "Schema properties missing from Tool" + ) + assert "name" in create_tool.parameters["properties"], ( + "name parameter missing from Tool schema" + ) + assert "description" in create_tool.parameters["properties"]["name"], ( + "Description missing from name parameter schema" + ) + assert ( + "PROP_DESCRIPTION" + in create_tool.parameters["properties"]["name"]["description"] + ), "Property description incorrect in schema" + + # --- CLIENT API TESTS --- + + async def test_client_api_resource_description(self, test_server): + """Test that Resource descriptions are accessible via the client API.""" + async with Client(test_server) as client: + resources = await client.list_resources() + list_resource = next((r for r in resources if r.name == "listItems"), None) + + assert list_resource is not None, ( + "listItems resource not accessible via client API" + ) + assert "LIST_DESCRIPTION" in (list_resource.description or ""), ( + "Route description missing in Resource from client API" + ) + + async def test_client_api_template_description(self, test_server): + """Test that ResourceTemplate descriptions are accessible via the client API.""" + async with Client(test_server) as client: + templates = await client.list_resource_templates() + get_template = next((t for t in templates if t.name == "getItem"), None) + + assert get_template is not None, ( + "getItem template not accessible via client API" + ) + assert "GET_DESCRIPTION" in (get_template.description or ""), ( + "Route description missing in ResourceTemplate from client API" + ) + + async def test_client_api_tool_description(self, test_server): + """Test that Tool descriptions are accessible via the client API.""" + async with Client(test_server) as client: + tools = await client.list_tools() + create_tool = next((t for t in tools if t.name == "createItem"), None) + + assert create_tool is not None, ( + "createItem tool not accessible via client API" + ) + assert "CREATE_DESCRIPTION" in (create_tool.description or ""), ( + "Route description missing in Tool from client API" + ) + + async def test_client_api_tool_parameter_schema(self, test_server): + """Test that Tool parameter schemas are accessible via the client API.""" + async with Client(test_server) as client: + tools = await client.list_tools() + create_tool = next((t for t in tools if t.name == "createItem"), None) + + assert create_tool is not None, ( + "createItem tool not accessible via client API" + ) + assert "properties" in create_tool.inputSchema, ( + "Schema properties missing from Tool inputSchema in client API" + ) + assert "name" in create_tool.inputSchema["properties"], ( + "name parameter missing from Tool schema in client API" + ) + assert "description" in create_tool.inputSchema["properties"]["name"], ( + "Description missing from name parameter in client API" + ) + assert ( + "PROP_DESCRIPTION" + in create_tool.inputSchema["properties"]["name"]["description"] + ), "Property description incorrect in schema from client API"