diff --git a/fastapi_mcp/openapi/convert.py b/fastapi_mcp/openapi/convert.py index 22e5c5e..e212d26 100644 --- a/fastapi_mcp/openapi/convert.py +++ b/fastapi_mcp/openapi/convert.py @@ -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) diff --git a/tests/fixtures/simple_app.py b/tests/fixtures/simple_app.py index 5d8298a..1486cbf 100644 --- a/tests/fixtures/simple_app.py +++ b/tests/fixtures/simple_app.py @@ -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"), @@ -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"), diff --git a/tests/test_openapi_conversion.py b/tests/test_openapi_conversion.py index aefe643..4282fbe 100644 --- a/tests/test_openapi_conversion.py +++ b/tests/test_openapi_conversion.py @@ -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( @@ -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( @@ -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