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
15 changes: 14 additions & 1 deletion fastapi_mcp/openapi/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,8 +259,21 @@ def convert_openapi_to_mcp_tools(
if required_props:
input_schema["required"] = required_props

# Build tool annotations
try:
annotations = operation["x-mcp-annotations"]
except KeyError:
annotations = None
else:
annotations = types.ToolAnnotations(**annotations)

# Create the MCP tool definition
tool = types.Tool(name=operation_id, description=tool_description, inputSchema=input_schema)
tool = types.Tool(
name=operation_id,
description=tool_description,
inputSchema=input_schema,
annotations=annotations,
)

tools.append(tool)

Expand Down
26 changes: 24 additions & 2 deletions tests/fixtures/simple_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,18 @@ def make_simple_fastapi_app(parametrized_config: dict[str, Any] | None = None) -
Item(id=3, name="Item 3", price=30.0, tags=["tag3", "tag4"], description="Item 3 description"),
]

@app.get("/items/", response_model=List[Item], tags=["items"], operation_id="list_items")
@app.get(
"/items/",
response_model=List[Item],
tags=["items"],
operation_id="list_items",
openapi_extra={
"x-mcp-annotations": {
"readOnlyHint": True,
"openWorldHint": False,
}
},
)
async def list_items(
skip: int = Query(0, description="Number of items to skip"),
limit: int = Query(10, description="Max number of items to return"),
Expand All @@ -25,7 +36,18 @@ async def list_items(
"""List all items with pagination and sorting options."""
return items[skip : skip + limit]

@app.get("/items/{item_id}", response_model=Item, tags=["items"], operation_id="get_item")
@app.get(
"/items/{item_id}",
response_model=Item,
tags=["items"],
operation_id="get_item",
openapi_extra={
"x-mcp-annotations": {
"readOnlyHint": True,
"openWorldHint": False,
}
},
)
async def read_item(
item_id: int = Path(..., description="The ID of the item to retrieve"),
include_details: bool = Query(False, description="Include additional details"),
Expand Down
53 changes: 53 additions & 0 deletions tests/test_openapi_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,22 @@ def test_simple_app_conversion(simple_fastapi_app: FastAPI):
assert tool.description is not None
assert tool.inputSchema is not None

# Verify annotations for list_items and get_item
list_items_tool = next(t for t in tools if t.name == "list_items")
assert list_items_tool.annotations is not None
assert list_items_tool.annotations.readOnlyHint is True
assert list_items_tool.annotations.openWorldHint is False

get_item_tool = next(t for t in tools if t.name == "get_item")
assert get_item_tool.annotations is not None
assert get_item_tool.annotations.readOnlyHint is True
assert get_item_tool.annotations.openWorldHint is False

# Verify no annotations for other operations
for tool in tools:
if tool.name not in ["list_items", "get_item"]:
assert tool.annotations is None


def test_complex_app_conversion(complex_fastapi_app: FastAPI):
openapi_schema = get_openapi(
Expand All @@ -59,6 +75,10 @@ def test_complex_app_conversion(complex_fastapi_app: FastAPI):
assert tool.description is not None
assert tool.inputSchema is not None

# Verify no annotations in complex_fastapi_app
for tool in tools:
assert tool.annotations is None


def test_describe_full_response_schema(simple_fastapi_app: FastAPI):
openapi_schema = get_openapi(
Expand Down Expand Up @@ -422,3 +442,36 @@ def test_body_params_edge_cases(complex_fastapi_app: FastAPI):
if "items" in properties:
item_props = properties["items"]["items"]["properties"]
assert "total" in item_props


def test_annotations_with_unknown_keys(simple_fastapi_app: FastAPI):
"""
Test that unknown keys in x-mcp-annotations are handled gracefully.
The MCP ToolAnnotations model accepts extra keys for forward compatibility.
"""
openapi_schema = get_openapi(
title=simple_fastapi_app.title,
version=simple_fastapi_app.version,
openapi_version=simple_fastapi_app.openapi_version,
description=simple_fastapi_app.description,
routes=simple_fastapi_app.routes,
)

# Add an unknown key to the annotations of list_items
list_items_path = openapi_schema["paths"]["/items/"]["get"]
list_items_path["x-mcp-annotations"]["unknownKey"] = "test_value"
list_items_path["x-mcp-annotations"]["anotherUnknown"] = 123

# This should not raise an error
tools, _ = convert_openapi_to_mcp_tools(openapi_schema)

list_items_tool = next(t for t in tools if t.name == "list_items")
assert list_items_tool.annotations is not None
assert list_items_tool.annotations.readOnlyHint is True
assert list_items_tool.annotations.openWorldHint is False

# Unknown keys should be preserved in the model's extra data
assert hasattr(list_items_tool.annotations, "unknownKey")
assert list_items_tool.annotations.unknownKey == "test_value"
assert hasattr(list_items_tool.annotations, "anotherUnknown")
assert list_items_tool.annotations.anotherUnknown == 123