Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ First off, thank you for considering contributing to FastAPI-MCP!

```bash
git clone https://github.com/YOUR-USERNAME/fastapi_mcp.git
cd fastapi-mcp
cd fastapi_mcp

# Add the upstream remote
git remote add upstream https://github.com/tadata-org/fastapi_mcp.git
Expand Down
5 changes: 4 additions & 1 deletion fastapi_mcp/openapi/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,10 @@ def convert_openapi_to_mcp_tools(
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:
Expand Down
9 changes: 7 additions & 2 deletions fastapi_mcp/openapi/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ def get_single_param_type_from_schema(param_schema: Dict[str, Any]) -> str:
"""
Get the type of a parameter from the schema.
If the schema is a union type, return the first type.

Args:
param_schema (Dict[str, Any]): Schema definition.
Returns:
str: The type of a parameter.
"""
if "anyOf" in param_schema:
types = {schema.get("type") for schema in param_schema["anyOf"] if schema.get("type")}
Expand All @@ -25,7 +30,7 @@ def resolve_schema_references(schema_part: Dict[str, Any], reference_schema: Dic
reference_schema: The complete schema used to resolve references from

Returns:
The schema with references resolved
dict: The schema with references resolved
"""
# Make a copy to avoid modifying the input schema
schema_part = schema_part.copy()
Expand Down Expand Up @@ -65,7 +70,7 @@ def clean_schema_for_display(schema: Dict[str, Any]) -> Dict[str, Any]:
schema: The schema to clean

Returns:
The cleaned schema
dict: The cleaned schema
"""
# Make a copy to avoid modifying the input schema
schema = schema.copy()
Expand Down
11 changes: 7 additions & 4 deletions fastapi_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -543,7 +543,8 @@ async def _execute_api_tool(
logger.debug(f"Making {method.upper()} request to {path}")
response = await self._request(client, method, path, query, headers, body)

# TODO: Better typing for the AsyncClientProtocol. It should return a ResponseProtocol that has a json() method that returns a dict/list/etc.
# TODO: Better typing for the AsyncClientProtocol. It should return a ResponseProtocol that has a json()
# method that returns a dict/list/etc.
try:
result = response.json()
result_text = json.dumps(result, indent=2, ensure_ascii=False)
Expand All @@ -553,8 +554,10 @@ async def _execute_api_tool(
else:
result_text = response.content

# If not raising an exception, the MCP server will return the result as a regular text response, without marking it as an error.
# TODO: Use a raise_for_status() method on the response (it needs to also be implemented in the AsyncClientProtocol)
# If not raising an exception, the MCP server will return the result as a regular text response,
# without marking it as an error.
# TODO: Use a raise_for_status() method on the response (it needs to also
# be implemented in the AsyncClientProtocol)
if 400 <= response.status_code < 600:
raise Exception(
f"Error calling {tool_name}. Status code: {response.status_code}. Response: {response.text}"
Expand Down Expand Up @@ -600,7 +603,7 @@ def _filter_tools(self, tools: List[types.Tool], openapi_schema: Dict[str, Any])
openapi_schema: The OpenAPI schema

Returns:
Filtered list of tools
list: Filtered list of tools
"""
if (
self._include_operations is None
Expand Down
119 changes: 43 additions & 76 deletions tests/test_mcp_execute_api_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,183 +10,150 @@
async def test_execute_api_tool_success(simple_fastapi_app: FastAPI):
"""Test successful execution of an API tool."""
mcp = FastApiMCP(simple_fastapi_app)

# Mock the HTTP client response
mock_response = MagicMock()
mock_response.json.return_value = {"id": 1, "name": "Test Item"}
mock_response.status_code = 200
mock_response.text = '{"id": 1, "name": "Test Item"}'

# Mock the HTTP client
mock_client = AsyncMock()
mock_client.get.return_value = mock_response

# Test parameters
tool_name = "get_item"
arguments = {"item_id": 1}

# Execute the tool
with patch.object(mcp, '_http_client', mock_client):
with patch.object(mcp, "_http_client", mock_client):
result = await mcp._execute_api_tool(
client=mock_client,
tool_name=tool_name,
arguments=arguments,
operation_map=mcp.operation_map
client=mock_client, tool_name=tool_name, arguments=arguments, operation_map=mcp.operation_map
)

# Verify the result
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == '{\n "id": 1,\n "name": "Test Item"\n}'

# Verify the HTTP client was called correctly
mock_client.get.assert_called_once_with(
"/items/1",
params={},
headers={}
)
mock_client.get.assert_called_once_with("/items/1", params={}, headers={})


@pytest.mark.asyncio
async def test_execute_api_tool_with_query_params(simple_fastapi_app: FastAPI):
"""Test execution of an API tool with query parameters."""
mcp = FastApiMCP(simple_fastapi_app)

# Mock the HTTP client response
mock_response = MagicMock()
mock_response.json.return_value = [{"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}]
mock_response.status_code = 200
mock_response.text = '[{"id": 1, "name": "Item 1"}, {"id": 2, "name": "Item 2"}]'

# Mock the HTTP client
mock_client = AsyncMock()
mock_client.get.return_value = mock_response

# Test parameters
tool_name = "list_items"
arguments = {"skip": 0, "limit": 2}

# Execute the tool
with patch.object(mcp, '_http_client', mock_client):
with patch.object(mcp, "_http_client", mock_client):
result = await mcp._execute_api_tool(
client=mock_client,
tool_name=tool_name,
arguments=arguments,
operation_map=mcp.operation_map
client=mock_client, tool_name=tool_name, arguments=arguments, operation_map=mcp.operation_map
)

# Verify the result
assert len(result) == 1
assert isinstance(result[0], TextContent)

# Verify the HTTP client was called with query parameters
mock_client.get.assert_called_once_with(
"/items/",
params={"skip": 0, "limit": 2},
headers={}
)
mock_client.get.assert_called_once_with("/items/", params={"skip": 0, "limit": 2}, headers={})


@pytest.mark.asyncio
async def test_execute_api_tool_with_body(simple_fastapi_app: FastAPI):
"""Test execution of an API tool with request body."""
mcp = FastApiMCP(simple_fastapi_app)

# Mock the HTTP client response
mock_response = MagicMock()
mock_response.json.return_value = {"id": 1, "name": "New Item"}
mock_response.status_code = 200
mock_response.text = '{"id": 1, "name": "New Item"}'

# Mock the HTTP client
mock_client = AsyncMock()
mock_client.post.return_value = mock_response

# Test parameters
tool_name = "create_item"
arguments = {
"item": {
"id": 1,
"name": "New Item",
"price": 10.0,
"tags": ["tag1"],
"description": "New item description"
}
"item": {"id": 1, "name": "New Item", "price": 10.0, "tags": ["tag1"], "description": "New item description"}
}

# Execute the tool
with patch.object(mcp, '_http_client', mock_client):
with patch.object(mcp, "_http_client", mock_client):
result = await mcp._execute_api_tool(
client=mock_client,
tool_name=tool_name,
arguments=arguments,
operation_map=mcp.operation_map
client=mock_client, tool_name=tool_name, arguments=arguments, operation_map=mcp.operation_map
)

# Verify the result
assert len(result) == 1
assert isinstance(result[0], TextContent)

# Verify the HTTP client was called with the request body
mock_client.post.assert_called_once_with(
"/items/",
params={},
headers={},
json=arguments
)
mock_client.post.assert_called_once_with("/items/", params={}, headers={}, json=arguments)


@pytest.mark.asyncio
async def test_execute_api_tool_with_non_ascii_chars(simple_fastapi_app: FastAPI):
"""Test execution of an API tool with non-ASCII characters."""
mcp = FastApiMCP(simple_fastapi_app)

# Test data with both ASCII and non-ASCII characters
test_data = {
"id": 1,
"name": "你好 World", # Chinese characters + ASCII
"price": 10.0,
"tags": ["tag1", "标签2"], # Chinese characters in tags
"description": "这是一个测试描述" # All Chinese characters
"description": "这是一个测试描述", # All Chinese characters
}

# Mock the HTTP client response
mock_response = MagicMock()
mock_response.json.return_value = test_data
mock_response.status_code = 200
mock_response.text = '{"id": 1, "name": "你好 World", "price": 10.0, "tags": ["tag1", "标签2"], "description": "这是一个测试描述"}'

mock_response.text = (
'{"id": 1, "name": "你好 World", "price": 10.0, "tags": ["tag1", "标签2"], "description": "这是一个测试描述"}'
)

# Mock the HTTP client
mock_client = AsyncMock()
mock_client.get.return_value = mock_response

# Test parameters
tool_name = "get_item"
arguments = {"item_id": 1}

# Execute the tool
with patch.object(mcp, '_http_client', mock_client):
with patch.object(mcp, "_http_client", mock_client):
result = await mcp._execute_api_tool(
client=mock_client,
tool_name=tool_name,
arguments=arguments,
operation_map=mcp.operation_map
client=mock_client, tool_name=tool_name, arguments=arguments, operation_map=mcp.operation_map
)

# Verify the result
assert len(result) == 1
assert isinstance(result[0], TextContent)

# Verify that the response contains both ASCII and non-ASCII characters
response_text = result[0].text
assert "你好" in response_text # Chinese characters preserved
assert "World" in response_text # ASCII characters preserved
assert "标签2" in response_text # Chinese characters in tags preserved
assert "这是一个测试描述" in response_text # All Chinese description preserved

# Verify the HTTP client was called correctly
mock_client.get.assert_called_once_with(
"/items/1",
params={},
headers={}
)
mock_client.get.assert_called_once_with("/items/1", params={}, headers={})