Spaces:
Running
Running
Merge pull request #287 from jlowin/openapi-params
Browse files- docs/patterns/openapi.mdx +32 -0
- src/fastmcp/server/openapi.py +29 -2
- tests/server/test_openapi.py +104 -2
docs/patterns/openapi.mdx
CHANGED
|
@@ -106,6 +106,38 @@ mcp = await FastMCP.from_openapi(
|
|
| 106 |
- It sends the request through the provided httpx client
|
| 107 |
- It translates the HTTP response to the appropriate MCP format
|
| 108 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
## Complete Example
|
| 110 |
|
| 111 |
```python [expandable]
|
|
|
|
| 106 |
- It sends the request through the provided httpx client
|
| 107 |
- It translates the HTTP response to the appropriate MCP format
|
| 108 |
|
| 109 |
+
### Request Parameter Handling
|
| 110 |
+
|
| 111 |
+
FastMCP carefully handles different types of parameters in OpenAPI requests:
|
| 112 |
+
|
| 113 |
+
#### Query Parameters
|
| 114 |
+
|
| 115 |
+
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.
|
| 116 |
+
|
| 117 |
+
For example, if you call a tool with these parameters:
|
| 118 |
+
```python
|
| 119 |
+
await client.call_tool("search_products", {
|
| 120 |
+
"category": "electronics", # Will be included
|
| 121 |
+
"min_price": 100, # Will be included
|
| 122 |
+
"max_price": None, # Will be excluded
|
| 123 |
+
"brand": "", # Will be excluded
|
| 124 |
+
})
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
The resulting HTTP request will only include `category=electronics&min_price=100`.
|
| 128 |
+
|
| 129 |
+
#### Path Parameters
|
| 130 |
+
|
| 131 |
+
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.
|
| 132 |
+
|
| 133 |
+
```python
|
| 134 |
+
# This will work
|
| 135 |
+
await client.call_tool("get_product", {"product_id": 123})
|
| 136 |
+
|
| 137 |
+
# This will raise ValueError: "Missing required path parameters: {'product_id'}"
|
| 138 |
+
await client.call_tool("get_product", {"product_id": None})
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
## Complete Example
|
| 142 |
|
| 143 |
```python [expandable]
|
src/fastmcp/server/openapi.py
CHANGED
|
@@ -149,19 +149,37 @@ class OpenAPITool(Tool):
|
|
| 149 |
path = self._route.path
|
| 150 |
|
| 151 |
# Replace path parameters with values from kwargs
|
|
|
|
|
|
|
| 152 |
path_params = {
|
| 153 |
p.name: kwargs.get(p.name)
|
| 154 |
for p in self._route.parameters
|
| 155 |
if p.location == "path"
|
|
|
|
|
|
|
| 156 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
for param_name, param_value in path_params.items():
|
| 158 |
path = path.replace(f"{{{param_name}}}", str(param_value))
|
| 159 |
|
| 160 |
-
# Prepare query parameters
|
| 161 |
query_params = {
|
| 162 |
p.name: kwargs.get(p.name)
|
| 163 |
for p in self._route.parameters
|
| 164 |
-
if p.location == "query"
|
|
|
|
|
|
|
|
|
|
| 165 |
}
|
| 166 |
|
| 167 |
# Prepare headers - fix typing by ensuring all values are strings
|
|
@@ -312,9 +330,18 @@ class OpenAPIResource(Resource):
|
|
| 312 |
for param_name, param_value in path_params.items():
|
| 313 |
path = path.replace(f"{{{param_name}}}", str(param_value))
|
| 314 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
response = await self._client.request(
|
| 316 |
method=self._route.method,
|
| 317 |
url=path,
|
|
|
|
| 318 |
timeout=self._timeout,
|
| 319 |
)
|
| 320 |
|
|
|
|
| 149 |
path = self._route.path
|
| 150 |
|
| 151 |
# Replace path parameters with values from kwargs
|
| 152 |
+
# Path parameters should never be None as they're typically required
|
| 153 |
+
# but we'll handle that case anyway
|
| 154 |
path_params = {
|
| 155 |
p.name: kwargs.get(p.name)
|
| 156 |
for p in self._route.parameters
|
| 157 |
if p.location == "path"
|
| 158 |
+
and p.name in kwargs
|
| 159 |
+
and kwargs.get(p.name) is not None
|
| 160 |
}
|
| 161 |
+
|
| 162 |
+
# Ensure all path parameters are provided
|
| 163 |
+
required_path_params = {
|
| 164 |
+
p.name
|
| 165 |
+
for p in self._route.parameters
|
| 166 |
+
if p.location == "path" and p.required
|
| 167 |
+
}
|
| 168 |
+
missing_params = required_path_params - path_params.keys()
|
| 169 |
+
if missing_params:
|
| 170 |
+
raise ValueError(f"Missing required path parameters: {missing_params}")
|
| 171 |
+
|
| 172 |
for param_name, param_value in path_params.items():
|
| 173 |
path = path.replace(f"{{{param_name}}}", str(param_value))
|
| 174 |
|
| 175 |
+
# Prepare query parameters - filter out None and empty strings
|
| 176 |
query_params = {
|
| 177 |
p.name: kwargs.get(p.name)
|
| 178 |
for p in self._route.parameters
|
| 179 |
+
if p.location == "query"
|
| 180 |
+
and p.name in kwargs
|
| 181 |
+
and kwargs.get(p.name) is not None
|
| 182 |
+
and kwargs.get(p.name) != ""
|
| 183 |
}
|
| 184 |
|
| 185 |
# Prepare headers - fix typing by ensuring all values are strings
|
|
|
|
| 330 |
for param_name, param_value in path_params.items():
|
| 331 |
path = path.replace(f"{{{param_name}}}", str(param_value))
|
| 332 |
|
| 333 |
+
# Filter any query parameters - get query parameters and filter out None/empty values
|
| 334 |
+
query_params = {}
|
| 335 |
+
for param in self._route.parameters:
|
| 336 |
+
if param.location == "query" and hasattr(self, f"_{param.name}"):
|
| 337 |
+
value = getattr(self, f"_{param.name}")
|
| 338 |
+
if value is not None and value != "":
|
| 339 |
+
query_params[param.name] = value
|
| 340 |
+
|
| 341 |
response = await self._client.request(
|
| 342 |
method=self._route.method,
|
| 343 |
url=path,
|
| 344 |
+
params=query_params,
|
| 345 |
timeout=self._timeout,
|
| 346 |
)
|
| 347 |
|
tests/server/test_openapi.py
CHANGED
|
@@ -14,6 +14,7 @@ from pydantic.networks import AnyUrl
|
|
| 14 |
|
| 15 |
from fastmcp import FastMCP
|
| 16 |
from fastmcp.client import Client
|
|
|
|
| 17 |
from fastmcp.server.openapi import (
|
| 18 |
FastMCPOpenAPI,
|
| 19 |
OpenAPIResource,
|
|
@@ -53,6 +54,22 @@ def fastapi_app(users_db: dict[int, User]) -> FastAPI:
|
|
| 53 |
"""Get all users."""
|
| 54 |
return sorted(users_db.values(), key=lambda x: x.id)
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
@app.get("/users/{user_id}", tags=["users", "detail"])
|
| 57 |
async def get_user(user_id: int) -> User | None:
|
| 58 |
"""Get a user by ID."""
|
|
@@ -304,7 +321,7 @@ class TestResources:
|
|
| 304 |
"""
|
| 305 |
async with Client(fastmcp_openapi_server) as client:
|
| 306 |
resources = await client.list_resources()
|
| 307 |
-
assert len(resources) ==
|
| 308 |
assert resources[0].uri == AnyUrl("resource://openapi/get_users_users_get")
|
| 309 |
assert resources[0].name == "get_users_users_get"
|
| 310 |
|
|
@@ -904,7 +921,7 @@ class TestMountFastMCP:
|
|
| 904 |
# Check that resources are available with prefixed URIs
|
| 905 |
async with Client(mcp) as client:
|
| 906 |
resources = await client.list_resources()
|
| 907 |
-
assert len(resources) ==
|
| 908 |
# We're checking the key used by mcp to store the resource
|
| 909 |
# The prefixed URI is used as the key, but the resource's original uri is preserved
|
| 910 |
prefixed_uri = "fastapi+resource://openapi/get_users_users_get"
|
|
@@ -932,3 +949,88 @@ class TestMountFastMCP:
|
|
| 932 |
async with Client(mcp) as client:
|
| 933 |
prompts = await client.list_prompts()
|
| 934 |
assert len(prompts) == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
from fastmcp import FastMCP
|
| 16 |
from fastmcp.client import Client
|
| 17 |
+
from fastmcp.exceptions import ClientError
|
| 18 |
from fastmcp.server.openapi import (
|
| 19 |
FastMCPOpenAPI,
|
| 20 |
OpenAPIResource,
|
|
|
|
| 54 |
"""Get all users."""
|
| 55 |
return sorted(users_db.values(), key=lambda x: x.id)
|
| 56 |
|
| 57 |
+
@app.get("/search", tags=["search"])
|
| 58 |
+
async def search_users(
|
| 59 |
+
name: str | None = None, active: bool | None = None, min_id: int | None = None
|
| 60 |
+
) -> list[User]:
|
| 61 |
+
"""Search users with optional filters."""
|
| 62 |
+
results = list(users_db.values())
|
| 63 |
+
|
| 64 |
+
if name is not None:
|
| 65 |
+
results = [u for u in results if name.lower() in u.name.lower()]
|
| 66 |
+
if active is not None:
|
| 67 |
+
results = [u for u in results if u.active == active]
|
| 68 |
+
if min_id is not None:
|
| 69 |
+
results = [u for u in results if u.id >= min_id]
|
| 70 |
+
|
| 71 |
+
return sorted(results, key=lambda x: x.id)
|
| 72 |
+
|
| 73 |
@app.get("/users/{user_id}", tags=["users", "detail"])
|
| 74 |
async def get_user(user_id: int) -> User | None:
|
| 75 |
"""Get a user by ID."""
|
|
|
|
| 321 |
"""
|
| 322 |
async with Client(fastmcp_openapi_server) as client:
|
| 323 |
resources = await client.list_resources()
|
| 324 |
+
assert len(resources) == 4
|
| 325 |
assert resources[0].uri == AnyUrl("resource://openapi/get_users_users_get")
|
| 326 |
assert resources[0].name == "get_users_users_get"
|
| 327 |
|
|
|
|
| 921 |
# Check that resources are available with prefixed URIs
|
| 922 |
async with Client(mcp) as client:
|
| 923 |
resources = await client.list_resources()
|
| 924 |
+
assert len(resources) == 4 # Updated to account for new search endpoint
|
| 925 |
# We're checking the key used by mcp to store the resource
|
| 926 |
# The prefixed URI is used as the key, but the resource's original uri is preserved
|
| 927 |
prefixed_uri = "fastapi+resource://openapi/get_users_users_get"
|
|
|
|
| 949 |
async with Client(mcp) as client:
|
| 950 |
prompts = await client.list_prompts()
|
| 951 |
assert len(prompts) == 0
|
| 952 |
+
|
| 953 |
+
|
| 954 |
+
async def test_empty_query_parameters_not_sent(
|
| 955 |
+
fastapi_app: FastAPI, api_client: httpx.AsyncClient
|
| 956 |
+
):
|
| 957 |
+
"""Test that empty and None query parameters are not sent in the request."""
|
| 958 |
+
|
| 959 |
+
# Create a TransportAdapter to track requests
|
| 960 |
+
class RequestCapture(httpx.AsyncBaseTransport):
|
| 961 |
+
def __init__(self, wrapped):
|
| 962 |
+
self.wrapped = wrapped
|
| 963 |
+
self.requests = []
|
| 964 |
+
|
| 965 |
+
async def handle_async_request(self, request):
|
| 966 |
+
self.requests.append(request)
|
| 967 |
+
return await self.wrapped.handle_async_request(request)
|
| 968 |
+
|
| 969 |
+
# Use our transport adapter to wrap the original one
|
| 970 |
+
capture = RequestCapture(api_client._transport)
|
| 971 |
+
api_client._transport = capture
|
| 972 |
+
|
| 973 |
+
# Create the OpenAPI server with new route map to make search endpoint a tool
|
| 974 |
+
openapi_spec = fastapi_app.openapi()
|
| 975 |
+
mcp_server = FastMCPOpenAPI(
|
| 976 |
+
openapi_spec=openapi_spec,
|
| 977 |
+
client=api_client,
|
| 978 |
+
route_maps=[
|
| 979 |
+
RouteMap(methods=["GET"], pattern=r".*", route_type=RouteType.TOOL)
|
| 980 |
+
],
|
| 981 |
+
)
|
| 982 |
+
|
| 983 |
+
# Call the search tool with mixed parameter values
|
| 984 |
+
async with Client(mcp_server) as client:
|
| 985 |
+
await client.call_tool(
|
| 986 |
+
"search_users_search_get",
|
| 987 |
+
{
|
| 988 |
+
"name": "", # Empty string should be excluded
|
| 989 |
+
"active": None, # None should be excluded
|
| 990 |
+
"min_id": 2, # Has value, should be included
|
| 991 |
+
},
|
| 992 |
+
)
|
| 993 |
+
|
| 994 |
+
# Verify that the request URL only has min_id parameter
|
| 995 |
+
assert len(capture.requests) > 0
|
| 996 |
+
request = capture.requests[-1] # Get the last request
|
| 997 |
+
|
| 998 |
+
# URL should only contain min_id=2, not name= or active=
|
| 999 |
+
url = str(request.url)
|
| 1000 |
+
assert "min_id=2" in url, f"URL should contain min_id=2, got: {url}"
|
| 1001 |
+
assert "name=" not in url, f"URL should not contain name=, got: {url}"
|
| 1002 |
+
assert "active=" not in url, f"URL should not contain active=, got: {url}"
|
| 1003 |
+
|
| 1004 |
+
# More direct check - parse the URL to examine query params
|
| 1005 |
+
from urllib.parse import parse_qs, urlparse
|
| 1006 |
+
|
| 1007 |
+
parsed_url = urlparse(url)
|
| 1008 |
+
query_params = parse_qs(parsed_url.query)
|
| 1009 |
+
|
| 1010 |
+
assert "min_id" in query_params
|
| 1011 |
+
assert "name" not in query_params
|
| 1012 |
+
assert "active" not in query_params
|
| 1013 |
+
|
| 1014 |
+
|
| 1015 |
+
async def test_none_path_parameters_rejected(
|
| 1016 |
+
fastapi_app: FastAPI, api_client: httpx.AsyncClient
|
| 1017 |
+
):
|
| 1018 |
+
"""Test that None values for path parameters are properly rejected."""
|
| 1019 |
+
# Create the OpenAPI server
|
| 1020 |
+
openapi_spec = fastapi_app.openapi()
|
| 1021 |
+
mcp_server = FastMCPOpenAPI(
|
| 1022 |
+
openapi_spec=openapi_spec,
|
| 1023 |
+
client=api_client,
|
| 1024 |
+
)
|
| 1025 |
+
|
| 1026 |
+
# Create a client and try to call a tool with a None path parameter
|
| 1027 |
+
async with Client(mcp_server) as client:
|
| 1028 |
+
# get_user has a required path parameter user_id
|
| 1029 |
+
with pytest.raises(ClientError, match="Missing required path parameters"):
|
| 1030 |
+
await client.call_tool(
|
| 1031 |
+
"update_user_name_users__user_id__name_patch",
|
| 1032 |
+
{
|
| 1033 |
+
"user_id": None, # This should cause an error
|
| 1034 |
+
"name": "New Name",
|
| 1035 |
+
},
|
| 1036 |
+
)
|