From 7e4f80e8cf641d8414aa05aa33c7121bdc1907e8 Mon Sep 17 00:00:00 2001 From: nicholas-schaub Date: Thu, 20 Mar 2025 23:39:22 -0400 Subject: [PATCH 1/6] Reconstruct full json schema for describe_full_response_schema --- fastapi_mcp/http_tools.py | 146 +++++++++++++++++++++++++++++--------- 1 file changed, 113 insertions(+), 33 deletions(-) diff --git a/fastapi_mcp/http_tools.py b/fastapi_mcp/http_tools.py index 3102257..b688ceb 100644 --- a/fastapi_mcp/http_tools.py +++ b/fastapi_mcp/http_tools.py @@ -12,12 +12,13 @@ from fastapi import FastAPI from fastapi.openapi.utils import get_openapi from mcp.server.fastmcp import FastMCP -from pydantic import Field logger = logging.getLogger("fastapi_mcp") -def resolve_schema_references(schema: Dict[str, Any], openapi_schema: Dict[str, Any]) -> Dict[str, Any]: +def resolve_schema_references( + schema: Dict[str, Any], openapi_schema: Dict[str, Any], top_schema=None +) -> Dict[str, Any]: """ Resolve schema references in OpenAPI schemas. @@ -31,28 +32,66 @@ def resolve_schema_references(schema: Dict[str, Any], openapi_schema: Dict[str, # Make a copy to avoid modifying the input schema schema = schema.copy() + # Create a a definnition prefix for the schema + def_prefix = "#/$defs/" + # Handle $ref directly in the schema if "$ref" in schema: ref_path = schema["$ref"] # Standard OpenAPI references are in the format "#/components/schemas/ModelName" if ref_path.startswith("#/components/schemas/"): model_name = ref_path.split("/")[-1] - if "components" in openapi_schema and "schemas" in openapi_schema["components"]: + if ( + "components" in openapi_schema + and "schemas" in openapi_schema["components"] + ): if model_name in openapi_schema["components"]["schemas"]: # Replace with the resolved schema - ref_schema = openapi_schema["components"]["schemas"][model_name].copy() - # Remove the $ref key and merge with the original schema - schema.pop("$ref") - schema.update(ref_schema) + ref_schema = openapi_schema["components"]["schemas"][ + model_name + ].copy() + + if top_schema is not None: + # Create the $defs key if it doesn't exist + if "$defs" not in top_schema: + top_schema["$defs"] = {} + + ref_schema = resolve_schema_references( + ref_schema, openapi_schema, top_schema=top_schema + ) + + # Create the definition reference + top_schema["$defs"][model_name] = ref_schema + + # Update the schema with the definition reference + schema["$ref"] = def_prefix + model_name + else: + # Update the schema with the definition reference + schema.pop("$ref") + schema.update(ref_schema) + top_schema = schema + + # Handle anyOf, oneOf, allOf + for key in ["anyOf", "oneOf", "allOf"]: + if key in schema: + for index, item in enumerate(schema[key]): + item = resolve_schema_references( + item, openapi_schema, top_schema=top_schema + ) + schema[key][index] = item # Handle array items if "type" in schema and schema["type"] == "array" and "items" in schema: - schema["items"] = resolve_schema_references(schema["items"], openapi_schema) + schema["items"] = resolve_schema_references( + schema["items"], openapi_schema, top_schema=top_schema + ) # Handle object properties if "properties" in schema: for prop_name, prop_schema in schema["properties"].items(): - schema["properties"][prop_name] = resolve_schema_references(prop_schema, openapi_schema) + schema["properties"][prop_name] = resolve_schema_references( + prop_schema, openapi_schema, top_schema=top_schema + ) return schema @@ -72,9 +111,6 @@ def clean_schema_for_display(schema: Dict[str, Any]) -> Dict[str, Any]: # Remove common internal fields that are not helpful for LLMs fields_to_remove = [ - "allOf", - "anyOf", - "oneOf", "nullable", "discriminator", "readOnly", @@ -227,7 +263,9 @@ def create_http_tool( responses_to_include = responses if not describe_all_responses and success_response: # If we're not describing all responses, only include the success response - success_code = next((code for code in success_codes if str(code) in responses), None) + success_code = next( + (code for code in success_codes if str(code) in responses), None + ) if success_code: responses_to_include = {str(success_code): success_response} @@ -248,7 +286,9 @@ def create_http_tool( response_info += f"\nContent-Type: {content_type}" # Resolve any schema references - resolved_schema = resolve_schema_references(schema, openapi_schema) + resolved_schema = resolve_schema_references( + schema, openapi_schema + ) # Clean the schema for display display_schema = clean_schema_for_display(resolved_schema) @@ -263,10 +303,16 @@ def create_http_tool( model_name = ref_path.split("/")[-1] response_info += f"\nModel: {model_name}" # Try to get examples from the model - model_examples = extract_model_examples_from_components(model_name, openapi_schema) + model_examples = extract_model_examples_from_components( + model_name, openapi_schema + ) # Check if this is an array of items - if schema.get("type") == "array" and "items" in schema and "$ref" in schema["items"]: + if ( + schema.get("type") == "array" + and "items" in schema + and "$ref" in schema["items"] + ): items_ref_path = schema["items"]["$ref"] if items_ref_path.startswith("#/components/schemas/"): items_model_name = items_ref_path.split("/")[-1] @@ -281,13 +327,17 @@ def create_http_tool( # Otherwise, try to create an example from the response definitions elif "examples" in response_data: # Use examples directly from response definition - for example_key, example_data in response_data["examples"].items(): + for example_key, example_data in response_data[ + "examples" + ].items(): if "value" in example_data: example_response = example_data["value"] break # If content has examples elif "examples" in content_data: - for example_key, example_data in content_data["examples"].items(): + for example_key, example_data in content_data[ + "examples" + ].items(): if "value" in example_data: example_response = example_data["value"] break @@ -325,7 +375,9 @@ def create_http_tool( response_info += "\n```" # Otherwise generate an example from the schema else: - generated_example = generate_example_from_schema(display_schema, model_name) + generated_example = generate_example_from_schema( + display_schema, model_name + ) if generated_example: response_info += "\n\n**Example Response:**\n```json\n" response_info += json.dumps(generated_example, indent=2) @@ -334,12 +386,13 @@ def create_http_tool( # Only include full schema information if requested if describe_full_response_schema: # Format schema information based on its type - if display_schema.get("type") == "array" and "items" in display_schema: + if ( + display_schema.get("type") == "array" + and "items" in display_schema + ): items_schema = display_schema["items"] - response_info += ( - "\n\n**Output Schema:** Array of items with the following structure:\n```json\n" - ) + response_info += "\n\n**Output Schema:** Array of items with the following structure:\n```json\n" response_info += json.dumps(items_schema, indent=2) response_info += "\n```" elif "properties" in display_schema: @@ -436,7 +489,7 @@ def create_http_tool( required_props.append(param_name) # Function to dynamically call the API endpoint - async def http_tool_function(kwargs: Dict[str, Any] = Field(default_factory=dict)): + async def http_tool_function(**kwargs): # Prepare URL with path parameters url = f"{base_url}{path}" for param_name, _ in path_params: @@ -464,13 +517,21 @@ async def http_tool_function(kwargs: Dict[str, Any] = Field(default_factory=dict if method.lower() == "get": response = await client.get(url, params=query, headers=headers) elif method.lower() == "post": - response = await client.post(url, params=query, headers=headers, json=body) + response = await client.post( + url, params=query, headers=headers, json=body + ) elif method.lower() == "put": - response = await client.put(url, params=query, headers=headers, json=body) + response = await client.put( + url, params=query, headers=headers, json=body + ) elif method.lower() == "delete": - response = await client.delete(url, params=query, headers=headers) + response = await client.delete( + url, params=query, headers=headers, json=body + ) elif method.lower() == "patch": - response = await client.patch(url, params=query, headers=headers, json=body) + response = await client.patch( + url, params=query, headers=headers, json=body + ) else: raise ValueError(f"Unsupported HTTP method: {method}") @@ -481,7 +542,11 @@ async def http_tool_function(kwargs: Dict[str, Any] = Field(default_factory=dict return response.text # Create a proper input schema for the tool - input_schema = {"type": "object", "properties": properties, "title": f"{operation_id}Arguments"} + input_schema = { + "type": "object", + "properties": properties, + "title": f"{operation_id}Arguments", + } if required_props: input_schema["required"] = required_props @@ -495,7 +560,9 @@ async def http_tool_function(kwargs: Dict[str, Any] = Field(default_factory=dict http_tool_function._input_schema = input_schema # type: ignore # Add tool to the MCP server with the enhanced schema - tool = mcp_server._tool_manager.add_tool(http_tool_function, name=operation_id, description=tool_description) + tool = mcp_server._tool_manager.add_tool( + http_tool_function, name=operation_id, description=tool_description + ) # Update the tool's parameters to use our custom schema instead of the auto-generated one tool.parameters = input_schema @@ -514,7 +581,10 @@ def extract_model_examples_from_components( Returns: List of example dictionaries if found, None otherwise """ - if "components" not in openapi_schema or "schemas" not in openapi_schema["components"]: + if ( + "components" not in openapi_schema + or "schemas" not in openapi_schema["components"] + ): return None if model_name not in openapi_schema["components"]["schemas"]: @@ -535,7 +605,9 @@ def extract_model_examples_from_components( return examples -def generate_example_from_schema(schema: Dict[str, Any], model_name: Optional[str] = None) -> Any: +def generate_example_from_schema( + schema: Dict[str, Any], model_name: Optional[str] = None +) -> Any: """ Generate a simple example response from a JSON schema. @@ -561,7 +633,15 @@ def generate_example_from_schema(schema: Dict[str, Any], model_name: Optional[st } elif model_name == "HTTPValidationError": # Create a realistic validation error example - return {"detail": [{"loc": ["body", "name"], "msg": "field required", "type": "value_error.missing"}]} + return { + "detail": [ + { + "loc": ["body", "name"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } # Handle different types schema_type = schema.get("type") From 134507dfbb1476bef4c87d7432bf540a90c1b3d1 Mon Sep 17 00:00:00 2001 From: nicholas-schaub Date: Thu, 20 Mar 2025 23:41:19 -0400 Subject: [PATCH 2/6] Passed qa checks --- fastapi_mcp/http_tools.py | 89 ++++++++++----------------------------- 1 file changed, 23 insertions(+), 66 deletions(-) diff --git a/fastapi_mcp/http_tools.py b/fastapi_mcp/http_tools.py index b688ceb..dcdc95e 100644 --- a/fastapi_mcp/http_tools.py +++ b/fastapi_mcp/http_tools.py @@ -41,24 +41,17 @@ def resolve_schema_references( # Standard OpenAPI references are in the format "#/components/schemas/ModelName" if ref_path.startswith("#/components/schemas/"): model_name = ref_path.split("/")[-1] - if ( - "components" in openapi_schema - and "schemas" in openapi_schema["components"] - ): + if "components" in openapi_schema and "schemas" in openapi_schema["components"]: if model_name in openapi_schema["components"]["schemas"]: # Replace with the resolved schema - ref_schema = openapi_schema["components"]["schemas"][ - model_name - ].copy() + ref_schema = openapi_schema["components"]["schemas"][model_name].copy() if top_schema is not None: # Create the $defs key if it doesn't exist if "$defs" not in top_schema: top_schema["$defs"] = {} - ref_schema = resolve_schema_references( - ref_schema, openapi_schema, top_schema=top_schema - ) + ref_schema = resolve_schema_references(ref_schema, openapi_schema, top_schema=top_schema) # Create the definition reference top_schema["$defs"][model_name] = ref_schema @@ -75,16 +68,12 @@ def resolve_schema_references( for key in ["anyOf", "oneOf", "allOf"]: if key in schema: for index, item in enumerate(schema[key]): - item = resolve_schema_references( - item, openapi_schema, top_schema=top_schema - ) + item = resolve_schema_references(item, openapi_schema, top_schema=top_schema) schema[key][index] = item # Handle array items if "type" in schema and schema["type"] == "array" and "items" in schema: - schema["items"] = resolve_schema_references( - schema["items"], openapi_schema, top_schema=top_schema - ) + schema["items"] = resolve_schema_references(schema["items"], openapi_schema, top_schema=top_schema) # Handle object properties if "properties" in schema: @@ -263,9 +252,7 @@ def create_http_tool( responses_to_include = responses if not describe_all_responses and success_response: # If we're not describing all responses, only include the success response - success_code = next( - (code for code in success_codes if str(code) in responses), None - ) + success_code = next((code for code in success_codes if str(code) in responses), None) if success_code: responses_to_include = {str(success_code): success_response} @@ -286,9 +273,7 @@ def create_http_tool( response_info += f"\nContent-Type: {content_type}" # Resolve any schema references - resolved_schema = resolve_schema_references( - schema, openapi_schema - ) + resolved_schema = resolve_schema_references(schema, openapi_schema) # Clean the schema for display display_schema = clean_schema_for_display(resolved_schema) @@ -303,16 +288,10 @@ def create_http_tool( model_name = ref_path.split("/")[-1] response_info += f"\nModel: {model_name}" # Try to get examples from the model - model_examples = extract_model_examples_from_components( - model_name, openapi_schema - ) + model_examples = extract_model_examples_from_components(model_name, openapi_schema) # Check if this is an array of items - if ( - schema.get("type") == "array" - and "items" in schema - and "$ref" in schema["items"] - ): + if schema.get("type") == "array" and "items" in schema and "$ref" in schema["items"]: items_ref_path = schema["items"]["$ref"] if items_ref_path.startswith("#/components/schemas/"): items_model_name = items_ref_path.split("/")[-1] @@ -327,17 +306,13 @@ def create_http_tool( # Otherwise, try to create an example from the response definitions elif "examples" in response_data: # Use examples directly from response definition - for example_key, example_data in response_data[ - "examples" - ].items(): + for example_key, example_data in response_data["examples"].items(): if "value" in example_data: example_response = example_data["value"] break # If content has examples elif "examples" in content_data: - for example_key, example_data in content_data[ - "examples" - ].items(): + for example_key, example_data in content_data["examples"].items(): if "value" in example_data: example_response = example_data["value"] break @@ -375,9 +350,7 @@ def create_http_tool( response_info += "\n```" # Otherwise generate an example from the schema else: - generated_example = generate_example_from_schema( - display_schema, model_name - ) + generated_example = generate_example_from_schema(display_schema, model_name) if generated_example: response_info += "\n\n**Example Response:**\n```json\n" response_info += json.dumps(generated_example, indent=2) @@ -386,13 +359,12 @@ def create_http_tool( # Only include full schema information if requested if describe_full_response_schema: # Format schema information based on its type - if ( - display_schema.get("type") == "array" - and "items" in display_schema - ): + if display_schema.get("type") == "array" and "items" in display_schema: items_schema = display_schema["items"] - response_info += "\n\n**Output Schema:** Array of items with the following structure:\n```json\n" + response_info += ( + "\n\n**Output Schema:** Array of items with the following structure:\n```json\n" + ) response_info += json.dumps(items_schema, indent=2) response_info += "\n```" elif "properties" in display_schema: @@ -517,21 +489,13 @@ async def http_tool_function(**kwargs): if method.lower() == "get": response = await client.get(url, params=query, headers=headers) elif method.lower() == "post": - response = await client.post( - url, params=query, headers=headers, json=body - ) + response = await client.post(url, params=query, headers=headers, json=body) elif method.lower() == "put": - response = await client.put( - url, params=query, headers=headers, json=body - ) + response = await client.put(url, params=query, headers=headers, json=body) elif method.lower() == "delete": - response = await client.delete( - url, params=query, headers=headers, json=body - ) + response = await client.delete(url, params=query, headers=headers, json=body) elif method.lower() == "patch": - response = await client.patch( - url, params=query, headers=headers, json=body - ) + response = await client.patch(url, params=query, headers=headers, json=body) else: raise ValueError(f"Unsupported HTTP method: {method}") @@ -560,9 +524,7 @@ async def http_tool_function(**kwargs): http_tool_function._input_schema = input_schema # type: ignore # Add tool to the MCP server with the enhanced schema - tool = mcp_server._tool_manager.add_tool( - http_tool_function, name=operation_id, description=tool_description - ) + tool = mcp_server._tool_manager.add_tool(http_tool_function, name=operation_id, description=tool_description) # Update the tool's parameters to use our custom schema instead of the auto-generated one tool.parameters = input_schema @@ -581,10 +543,7 @@ def extract_model_examples_from_components( Returns: List of example dictionaries if found, None otherwise """ - if ( - "components" not in openapi_schema - or "schemas" not in openapi_schema["components"] - ): + if "components" not in openapi_schema or "schemas" not in openapi_schema["components"]: return None if model_name not in openapi_schema["components"]["schemas"]: @@ -605,9 +564,7 @@ def extract_model_examples_from_components( return examples -def generate_example_from_schema( - schema: Dict[str, Any], model_name: Optional[str] = None -) -> Any: +def generate_example_from_schema(schema: Dict[str, Any], model_name: Optional[str] = None) -> Any: """ Generate a simple example response from a JSON schema. From 540001f86c2c1d38ae37b86072002d16e580ea33 Mon Sep 17 00:00:00 2001 From: nicholas-schaub Date: Thu, 20 Mar 2025 23:44:41 -0400 Subject: [PATCH 3/6] Revert changes --- fastapi_mcp/http_tools.py | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/fastapi_mcp/http_tools.py b/fastapi_mcp/http_tools.py index dcdc95e..3f23935 100644 --- a/fastapi_mcp/http_tools.py +++ b/fastapi_mcp/http_tools.py @@ -461,7 +461,7 @@ def create_http_tool( required_props.append(param_name) # Function to dynamically call the API endpoint - async def http_tool_function(**kwargs): + async def http_tool_function(kwargs: Dict[str, Any] = Field(default_factory=dict)): # Prepare URL with path parameters url = f"{base_url}{path}" for param_name, _ in path_params: @@ -493,7 +493,7 @@ async def http_tool_function(**kwargs): elif method.lower() == "put": response = await client.put(url, params=query, headers=headers, json=body) elif method.lower() == "delete": - response = await client.delete(url, params=query, headers=headers, json=body) + response = await client.delete(url, params=query, headers=headers) elif method.lower() == "patch": response = await client.patch(url, params=query, headers=headers, json=body) else: @@ -506,11 +506,7 @@ async def http_tool_function(**kwargs): return response.text # Create a proper input schema for the tool - input_schema = { - "type": "object", - "properties": properties, - "title": f"{operation_id}Arguments", - } + input_schema = {"type": "object", "properties": properties, "title": f"{operation_id}Arguments"} if required_props: input_schema["required"] = required_props @@ -590,15 +586,7 @@ def generate_example_from_schema(schema: Dict[str, Any], model_name: Optional[st } elif model_name == "HTTPValidationError": # Create a realistic validation error example - return { - "detail": [ - { - "loc": ["body", "name"], - "msg": "field required", - "type": "value_error.missing", - } - ] - } + return {"detail": [{"loc": ["body", "name"], "msg": "field required", "type": "value_error.missing"}]} # Handle different types schema_type = schema.get("type") @@ -648,4 +636,4 @@ def generate_example_from_schema(schema: Dict[str, Any], model_name: Optional[st return None # Default case - return None + return None \ No newline at end of file From d27a20c1c6f58dc1f9ab16640985b6f6f86d0361 Mon Sep 17 00:00:00 2001 From: nicholas-schaub Date: Thu, 20 Mar 2025 23:46:42 -0400 Subject: [PATCH 4/6] Fixed indentation and import error --- fastapi_mcp/http_tools.py | 106 ++++++++++++++++++++++++++++---------- 1 file changed, 80 insertions(+), 26 deletions(-) diff --git a/fastapi_mcp/http_tools.py b/fastapi_mcp/http_tools.py index 3f23935..0d86bee 100644 --- a/fastapi_mcp/http_tools.py +++ b/fastapi_mcp/http_tools.py @@ -12,6 +12,7 @@ from fastapi import FastAPI from fastapi.openapi.utils import get_openapi from mcp.server.fastmcp import FastMCP +from pydantic import Field logger = logging.getLogger("fastapi_mcp") @@ -41,17 +42,24 @@ def resolve_schema_references( # Standard OpenAPI references are in the format "#/components/schemas/ModelName" if ref_path.startswith("#/components/schemas/"): model_name = ref_path.split("/")[-1] - if "components" in openapi_schema and "schemas" in openapi_schema["components"]: + if ( + "components" in openapi_schema + and "schemas" in openapi_schema["components"] + ): if model_name in openapi_schema["components"]["schemas"]: # Replace with the resolved schema - ref_schema = openapi_schema["components"]["schemas"][model_name].copy() + ref_schema = openapi_schema["components"]["schemas"][ + model_name + ].copy() if top_schema is not None: # Create the $defs key if it doesn't exist if "$defs" not in top_schema: top_schema["$defs"] = {} - ref_schema = resolve_schema_references(ref_schema, openapi_schema, top_schema=top_schema) + ref_schema = resolve_schema_references( + ref_schema, openapi_schema, top_schema=top_schema + ) # Create the definition reference top_schema["$defs"][model_name] = ref_schema @@ -68,12 +76,16 @@ def resolve_schema_references( for key in ["anyOf", "oneOf", "allOf"]: if key in schema: for index, item in enumerate(schema[key]): - item = resolve_schema_references(item, openapi_schema, top_schema=top_schema) + item = resolve_schema_references( + item, openapi_schema, top_schema=top_schema + ) schema[key][index] = item # Handle array items if "type" in schema and schema["type"] == "array" and "items" in schema: - schema["items"] = resolve_schema_references(schema["items"], openapi_schema, top_schema=top_schema) + schema["items"] = resolve_schema_references( + schema["items"], openapi_schema, top_schema=top_schema + ) # Handle object properties if "properties" in schema: @@ -252,7 +264,9 @@ def create_http_tool( responses_to_include = responses if not describe_all_responses and success_response: # If we're not describing all responses, only include the success response - success_code = next((code for code in success_codes if str(code) in responses), None) + success_code = next( + (code for code in success_codes if str(code) in responses), None + ) if success_code: responses_to_include = {str(success_code): success_response} @@ -273,7 +287,9 @@ def create_http_tool( response_info += f"\nContent-Type: {content_type}" # Resolve any schema references - resolved_schema = resolve_schema_references(schema, openapi_schema) + resolved_schema = resolve_schema_references( + schema, openapi_schema + ) # Clean the schema for display display_schema = clean_schema_for_display(resolved_schema) @@ -288,10 +304,16 @@ def create_http_tool( model_name = ref_path.split("/")[-1] response_info += f"\nModel: {model_name}" # Try to get examples from the model - model_examples = extract_model_examples_from_components(model_name, openapi_schema) + model_examples = extract_model_examples_from_components( + model_name, openapi_schema + ) # Check if this is an array of items - if schema.get("type") == "array" and "items" in schema and "$ref" in schema["items"]: + if ( + schema.get("type") == "array" + and "items" in schema + and "$ref" in schema["items"] + ): items_ref_path = schema["items"]["$ref"] if items_ref_path.startswith("#/components/schemas/"): items_model_name = items_ref_path.split("/")[-1] @@ -306,13 +328,17 @@ def create_http_tool( # Otherwise, try to create an example from the response definitions elif "examples" in response_data: # Use examples directly from response definition - for example_key, example_data in response_data["examples"].items(): + for example_key, example_data in response_data[ + "examples" + ].items(): if "value" in example_data: example_response = example_data["value"] break # If content has examples elif "examples" in content_data: - for example_key, example_data in content_data["examples"].items(): + for example_key, example_data in content_data[ + "examples" + ].items(): if "value" in example_data: example_response = example_data["value"] break @@ -350,7 +376,9 @@ def create_http_tool( response_info += "\n```" # Otherwise generate an example from the schema else: - generated_example = generate_example_from_schema(display_schema, model_name) + generated_example = generate_example_from_schema( + display_schema, model_name + ) if generated_example: response_info += "\n\n**Example Response:**\n```json\n" response_info += json.dumps(generated_example, indent=2) @@ -359,12 +387,13 @@ def create_http_tool( # Only include full schema information if requested if describe_full_response_schema: # Format schema information based on its type - if display_schema.get("type") == "array" and "items" in display_schema: + if ( + display_schema.get("type") == "array" + and "items" in display_schema + ): items_schema = display_schema["items"] - response_info += ( - "\n\n**Output Schema:** Array of items with the following structure:\n```json\n" - ) + response_info += "\n\n**Output Schema:** Array of items with the following structure:\n```json\n" response_info += json.dumps(items_schema, indent=2) response_info += "\n```" elif "properties" in display_schema: @@ -461,7 +490,7 @@ def create_http_tool( required_props.append(param_name) # Function to dynamically call the API endpoint - async def http_tool_function(kwargs: Dict[str, Any] = Field(default_factory=dict)): + async def http_tool_function(kwargs: Dict[str, Any] = Field(default_factory=dict)): # Prepare URL with path parameters url = f"{base_url}{path}" for param_name, _ in path_params: @@ -489,13 +518,19 @@ async def http_tool_function(kwargs: Dict[str, Any] = Field(default_factory=dict if method.lower() == "get": response = await client.get(url, params=query, headers=headers) elif method.lower() == "post": - response = await client.post(url, params=query, headers=headers, json=body) + response = await client.post( + url, params=query, headers=headers, json=body + ) elif method.lower() == "put": - response = await client.put(url, params=query, headers=headers, json=body) + response = await client.put( + url, params=query, headers=headers, json=body + ) elif method.lower() == "delete": response = await client.delete(url, params=query, headers=headers) elif method.lower() == "patch": - response = await client.patch(url, params=query, headers=headers, json=body) + response = await client.patch( + url, params=query, headers=headers, json=body + ) else: raise ValueError(f"Unsupported HTTP method: {method}") @@ -506,7 +541,11 @@ async def http_tool_function(kwargs: Dict[str, Any] = Field(default_factory=dict return response.text # Create a proper input schema for the tool - input_schema = {"type": "object", "properties": properties, "title": f"{operation_id}Arguments"} + input_schema = { + "type": "object", + "properties": properties, + "title": f"{operation_id}Arguments", + } if required_props: input_schema["required"] = required_props @@ -520,7 +559,9 @@ async def http_tool_function(kwargs: Dict[str, Any] = Field(default_factory=dict http_tool_function._input_schema = input_schema # type: ignore # Add tool to the MCP server with the enhanced schema - tool = mcp_server._tool_manager.add_tool(http_tool_function, name=operation_id, description=tool_description) + tool = mcp_server._tool_manager.add_tool( + http_tool_function, name=operation_id, description=tool_description + ) # Update the tool's parameters to use our custom schema instead of the auto-generated one tool.parameters = input_schema @@ -539,7 +580,10 @@ def extract_model_examples_from_components( Returns: List of example dictionaries if found, None otherwise """ - if "components" not in openapi_schema or "schemas" not in openapi_schema["components"]: + if ( + "components" not in openapi_schema + or "schemas" not in openapi_schema["components"] + ): return None if model_name not in openapi_schema["components"]["schemas"]: @@ -560,7 +604,9 @@ def extract_model_examples_from_components( return examples -def generate_example_from_schema(schema: Dict[str, Any], model_name: Optional[str] = None) -> Any: +def generate_example_from_schema( + schema: Dict[str, Any], model_name: Optional[str] = None +) -> Any: """ Generate a simple example response from a JSON schema. @@ -586,7 +632,15 @@ def generate_example_from_schema(schema: Dict[str, Any], model_name: Optional[st } elif model_name == "HTTPValidationError": # Create a realistic validation error example - return {"detail": [{"loc": ["body", "name"], "msg": "field required", "type": "value_error.missing"}]} + return { + "detail": [ + { + "loc": ["body", "name"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } # Handle different types schema_type = schema.get("type") @@ -636,4 +690,4 @@ def generate_example_from_schema(schema: Dict[str, Any], model_name: Optional[st return None # Default case - return None \ No newline at end of file + return None From 6f7eeb837bb1b86a05a92d585ea0b3349fb11ec8 Mon Sep 17 00:00:00 2001 From: nicholas-schaub Date: Thu, 20 Mar 2025 23:47:39 -0400 Subject: [PATCH 5/6] Fixed QA issue --- fastapi_mcp/http_tools.py | 85 ++++++++++----------------------------- 1 file changed, 22 insertions(+), 63 deletions(-) diff --git a/fastapi_mcp/http_tools.py b/fastapi_mcp/http_tools.py index 0d86bee..aecfbfa 100644 --- a/fastapi_mcp/http_tools.py +++ b/fastapi_mcp/http_tools.py @@ -42,24 +42,17 @@ def resolve_schema_references( # Standard OpenAPI references are in the format "#/components/schemas/ModelName" if ref_path.startswith("#/components/schemas/"): model_name = ref_path.split("/")[-1] - if ( - "components" in openapi_schema - and "schemas" in openapi_schema["components"] - ): + if "components" in openapi_schema and "schemas" in openapi_schema["components"]: if model_name in openapi_schema["components"]["schemas"]: # Replace with the resolved schema - ref_schema = openapi_schema["components"]["schemas"][ - model_name - ].copy() + ref_schema = openapi_schema["components"]["schemas"][model_name].copy() if top_schema is not None: # Create the $defs key if it doesn't exist if "$defs" not in top_schema: top_schema["$defs"] = {} - ref_schema = resolve_schema_references( - ref_schema, openapi_schema, top_schema=top_schema - ) + ref_schema = resolve_schema_references(ref_schema, openapi_schema, top_schema=top_schema) # Create the definition reference top_schema["$defs"][model_name] = ref_schema @@ -76,16 +69,12 @@ def resolve_schema_references( for key in ["anyOf", "oneOf", "allOf"]: if key in schema: for index, item in enumerate(schema[key]): - item = resolve_schema_references( - item, openapi_schema, top_schema=top_schema - ) + item = resolve_schema_references(item, openapi_schema, top_schema=top_schema) schema[key][index] = item # Handle array items if "type" in schema and schema["type"] == "array" and "items" in schema: - schema["items"] = resolve_schema_references( - schema["items"], openapi_schema, top_schema=top_schema - ) + schema["items"] = resolve_schema_references(schema["items"], openapi_schema, top_schema=top_schema) # Handle object properties if "properties" in schema: @@ -264,9 +253,7 @@ def create_http_tool( responses_to_include = responses if not describe_all_responses and success_response: # If we're not describing all responses, only include the success response - success_code = next( - (code for code in success_codes if str(code) in responses), None - ) + success_code = next((code for code in success_codes if str(code) in responses), None) if success_code: responses_to_include = {str(success_code): success_response} @@ -287,9 +274,7 @@ def create_http_tool( response_info += f"\nContent-Type: {content_type}" # Resolve any schema references - resolved_schema = resolve_schema_references( - schema, openapi_schema - ) + resolved_schema = resolve_schema_references(schema, openapi_schema) # Clean the schema for display display_schema = clean_schema_for_display(resolved_schema) @@ -304,16 +289,10 @@ def create_http_tool( model_name = ref_path.split("/")[-1] response_info += f"\nModel: {model_name}" # Try to get examples from the model - model_examples = extract_model_examples_from_components( - model_name, openapi_schema - ) + model_examples = extract_model_examples_from_components(model_name, openapi_schema) # Check if this is an array of items - if ( - schema.get("type") == "array" - and "items" in schema - and "$ref" in schema["items"] - ): + if schema.get("type") == "array" and "items" in schema and "$ref" in schema["items"]: items_ref_path = schema["items"]["$ref"] if items_ref_path.startswith("#/components/schemas/"): items_model_name = items_ref_path.split("/")[-1] @@ -328,17 +307,13 @@ def create_http_tool( # Otherwise, try to create an example from the response definitions elif "examples" in response_data: # Use examples directly from response definition - for example_key, example_data in response_data[ - "examples" - ].items(): + for example_key, example_data in response_data["examples"].items(): if "value" in example_data: example_response = example_data["value"] break # If content has examples elif "examples" in content_data: - for example_key, example_data in content_data[ - "examples" - ].items(): + for example_key, example_data in content_data["examples"].items(): if "value" in example_data: example_response = example_data["value"] break @@ -376,9 +351,7 @@ def create_http_tool( response_info += "\n```" # Otherwise generate an example from the schema else: - generated_example = generate_example_from_schema( - display_schema, model_name - ) + generated_example = generate_example_from_schema(display_schema, model_name) if generated_example: response_info += "\n\n**Example Response:**\n```json\n" response_info += json.dumps(generated_example, indent=2) @@ -387,13 +360,12 @@ def create_http_tool( # Only include full schema information if requested if describe_full_response_schema: # Format schema information based on its type - if ( - display_schema.get("type") == "array" - and "items" in display_schema - ): + if display_schema.get("type") == "array" and "items" in display_schema: items_schema = display_schema["items"] - response_info += "\n\n**Output Schema:** Array of items with the following structure:\n```json\n" + response_info += ( + "\n\n**Output Schema:** Array of items with the following structure:\n```json\n" + ) response_info += json.dumps(items_schema, indent=2) response_info += "\n```" elif "properties" in display_schema: @@ -518,19 +490,13 @@ async def http_tool_function(kwargs: Dict[str, Any] = Field(default_factory=dict if method.lower() == "get": response = await client.get(url, params=query, headers=headers) elif method.lower() == "post": - response = await client.post( - url, params=query, headers=headers, json=body - ) + response = await client.post(url, params=query, headers=headers, json=body) elif method.lower() == "put": - response = await client.put( - url, params=query, headers=headers, json=body - ) + response = await client.put(url, params=query, headers=headers, json=body) elif method.lower() == "delete": response = await client.delete(url, params=query, headers=headers) elif method.lower() == "patch": - response = await client.patch( - url, params=query, headers=headers, json=body - ) + response = await client.patch(url, params=query, headers=headers, json=body) else: raise ValueError(f"Unsupported HTTP method: {method}") @@ -559,9 +525,7 @@ async def http_tool_function(kwargs: Dict[str, Any] = Field(default_factory=dict http_tool_function._input_schema = input_schema # type: ignore # Add tool to the MCP server with the enhanced schema - tool = mcp_server._tool_manager.add_tool( - http_tool_function, name=operation_id, description=tool_description - ) + tool = mcp_server._tool_manager.add_tool(http_tool_function, name=operation_id, description=tool_description) # Update the tool's parameters to use our custom schema instead of the auto-generated one tool.parameters = input_schema @@ -580,10 +544,7 @@ def extract_model_examples_from_components( Returns: List of example dictionaries if found, None otherwise """ - if ( - "components" not in openapi_schema - or "schemas" not in openapi_schema["components"] - ): + if "components" not in openapi_schema or "schemas" not in openapi_schema["components"]: return None if model_name not in openapi_schema["components"]["schemas"]: @@ -604,9 +565,7 @@ def extract_model_examples_from_components( return examples -def generate_example_from_schema( - schema: Dict[str, Any], model_name: Optional[str] = None -) -> Any: +def generate_example_from_schema(schema: Dict[str, Any], model_name: Optional[str] = None) -> Any: """ Generate a simple example response from a JSON schema. From ad57957953a1832617ff809711aa8bc9e100e607 Mon Sep 17 00:00:00 2001 From: nicholas-schaub Date: Fri, 21 Mar 2025 11:41:19 -0400 Subject: [PATCH 6/6] Added unit tests to check output schema. --- tests/test_http_tools.py | 16 +++++++- tests/test_tool_generation.py | 74 ++++++++++++++++++++++++++++++++--- 2 files changed, 83 insertions(+), 7 deletions(-) diff --git a/tests/test_http_tools.py b/tests/test_http_tools.py index f8193ae..302d1b9 100644 --- a/tests/test_http_tools.py +++ b/tests/test_http_tools.py @@ -86,7 +86,13 @@ def test_resolve_schema_references(): openapi_schema = { "components": { "schemas": { - "Item": {"type": "object", "properties": {"id": {"type": "integer"}, "name": {"type": "string"}}} + "Item": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + }, + } } } } @@ -141,7 +147,13 @@ def test_create_mcp_tools_from_complex_app(complex_app): assert len(api_tools) == 5, f"Expected 5 API tools, got {len(api_tools)}" # Check for all expected tools with the correct name pattern - tool_operations = ["list_items", "read_item", "create_item", "update_item", "delete_item"] + tool_operations = [ + "list_items", + "read_item", + "create_item", + "update_item", + "delete_item", + ] for operation in tool_operations: matching_tools = [t for t in tools if operation in t.name] assert len(matching_tools) > 0, f"No tool found for operation '{operation}'" diff --git a/tests/test_tool_generation.py b/tests/test_tool_generation.py index 25102d1..e7bcc2d 100644 --- a/tests/test_tool_generation.py +++ b/tests/test_tool_generation.py @@ -1,3 +1,5 @@ +import json + import pytest from fastapi import FastAPI from pydantic import BaseModel @@ -14,6 +16,29 @@ class Item(BaseModel): tags: List[str] = [] +class Task(BaseModel): + id: int + title: str + description: Optional[str] = None + completed: bool = False + required_resources: List[Item] = [] + + +def remove_default_values(schema: dict) -> dict: + if "default" in schema: + schema.pop("default") + + for value in schema.values(): + if isinstance(value, dict): + remove_default_values(value) + + return schema + + +def normalize_json_schema(schema: dict) -> str: + return json.dumps(remove_default_values(schema), sort_keys=True) + + @pytest.fixture def sample_app(): """Create a sample FastAPI app for testing.""" @@ -50,6 +75,14 @@ async def create_item(item: Item): """ return item + @app.get("/tasks/", response_model=List[Task], tags=["tasks"]) + async def list_tasks( + skip: int = 0, + limit: int = 10, + ): + """List all tasks with pagination options.""" + return [] + return app @@ -96,7 +129,10 @@ def test_tool_generation_with_full_schema(sample_app): """Test that MCP tools include full response schema when requested.""" # Create MCP server with full schema for all operations mcp_server = add_mcp_server( - sample_app, serve_tools=True, base_url="http://localhost:8000", describe_full_response_schema=True + sample_app, + serve_tools=True, + base_url="http://localhost:8000", + describe_full_response_schema=True, ) # Extract tools for inspection @@ -109,16 +145,44 @@ def test_tool_generation_with_full_schema(sample_app): continue description = tool.description - # Check that the tool includes information about the Item schema - assert "Item" in description, f"Item schema should be included in the description for {tool.name}" - assert "price" in description, f"Item properties should be included in the description for {tool.name}" + + # Check that the tool includes information about the Item or Task schema + if tool.name == "list_tasks_tasks__get": + model = Task + elif "Item" in description: + model = Item + elif "Task" not in description: + raise ValueError(f"Item or Task schema should be included in the description for {tool.name}") + + assert "price" in description or "required_resources" in description, ( + f"Item or Task properties should be included in the description for {tool.name}" + ) + + # Get the output schema from the description + lines = description.split("\n") + for index, line in enumerate(lines): + if "Output Schema" in line: + index += 2 + break + + # Normalize the output schema + output_schema_str = normalize_json_schema(json.loads("\n".join(lines[index:-1]))) + + # Generate and normalize the model schema + model_schema_str = normalize_json_schema(model.model_json_schema()) + + # Check that the output schema matches the model schema + assert output_schema_str == model_schema_str, f"Output schema does not match model schema for {tool.name}" def test_tool_generation_with_all_responses(sample_app): """Test that MCP tools include all possible responses when requested.""" # Create MCP server with all response status codes mcp_server = add_mcp_server( - sample_app, serve_tools=True, base_url="http://localhost:8000", describe_all_responses=True + sample_app, + serve_tools=True, + base_url="http://localhost:8000", + describe_all_responses=True, ) # Extract tools for inspection