diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py index 19cac311d..d25dc314d 100644 --- a/src/fastmcp/server/openapi.py +++ b/src/fastmcp/server/openapi.py @@ -115,6 +115,7 @@ class OpenAPITool(Tool): parameters: dict[str, Any], fn_metadata: Any, is_async: bool = True, + tags: set[str] = set(), ): super().__init__( name=name, @@ -124,6 +125,7 @@ class OpenAPITool(Tool): fn_metadata=fn_metadata, is_async=is_async, context_kwarg="context", # Default context keyword argument + tags=tags, ) self._client = client self._route = route @@ -242,12 +244,14 @@ class OpenAPIResource(Resource): name: str, description: str, mime_type: str = "application/json", + tags: set[str] = set(), ): super().__init__( uri=AnyUrl(uri), # Convert string to AnyUrl name=name, description=description, mime_type=mime_type, + tags=tags, ) self._client = client self._route = route @@ -332,6 +336,7 @@ class OpenAPIResourceTemplate(ResourceTemplate): name: str, description: str, parameters: dict[str, Any], + tags: set[str] = set(), ): super().__init__( uri_template=uri_template, @@ -339,6 +344,7 @@ class OpenAPIResourceTemplate(ResourceTemplate): description=description, fn=self._create_resource_fn, parameters=parameters, + tags=tags, ) self._client = client self._route = route @@ -405,6 +411,7 @@ class OpenAPIResourceTemplate(ResourceTemplate): description=self.description or f"Resource for {self._route.path}", # Provide default if None mime_type="application/json", # Default, will be updated when read + tags=set(self._route.tags or []), ) @@ -525,10 +532,13 @@ class FastMCPOpenAPI(FastMCP): parameters=combined_schema, fn_metadata=func_metadata(_openapi_passthrough), is_async=True, + tags=set(route.tags or []), ) # Register the tool by directly assigning to the tools dictionary self._tool_manager._tools[tool_name] = tool - logger.debug(f"Registered TOOL: {tool_name} ({route.method} {route.path})") + logger.debug( + f"Registered TOOL: {tool_name} ({route.method} {route.path}) with tags: {route.tags}" + ) def _create_openapi_resource(self, route: openapi.HTTPRoute, operation_id: str): """Creates and registers an OpenAPIResource with enhanced description.""" @@ -550,11 +560,12 @@ class FastMCPOpenAPI(FastMCP): uri=resource_uri, name=resource_name, description=enhanced_description, + tags=set(route.tags or []), ) # Register the resource by directly assigning to the resources dictionary self._resource_manager._resources[str(resource.uri)] = resource logger.debug( - f"Registered RESOURCE: {resource_uri} ({route.method} {route.path})" + f"Registered RESOURCE: {resource_uri} ({route.method} {route.path}) with tags: {route.tags}" ) def _create_openapi_template(self, route: openapi.HTTPRoute, operation_id: str): @@ -594,11 +605,12 @@ class FastMCPOpenAPI(FastMCP): name=template_name, description=enhanced_description, parameters=template_params_schema, + tags=set(route.tags or []), ) # Register the template by directly assigning to the templates dictionary self._resource_manager._templates[uri_template_str] = template logger.debug( - f"Registered TEMPLATE: {uri_template_str} ({route.method} {route.path})" + f"Registered TEMPLATE: {uri_template_str} ({route.method} {route.path}) with tags: {route.tags}" ) async def call_tool(self, name: str, arguments: dict[str, Any]) -> Any: diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index f49e58e2f..b307eea70 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -3,7 +3,7 @@ import inspect import json import re -from collections.abc import AsyncIterator, Callable, Sequence +from collections.abc import AsyncIterator, Callable from contextlib import ( AbstractAsyncContextManager, asynccontextmanager, @@ -78,8 +78,10 @@ class FastMCP(Generic[LifespanResultT]): lifespan: ( Callable[["FastMCP"], AbstractAsyncContextManager[LifespanResultT]] | None ) = None, + tags: set[str] | None = None, **settings: Any, ): + self.tags: set[str] = tags or set() self.settings = fastmcp.settings.ServerSettings(**settings) self._mcp_server = MCPServer[LifespanResultT]( @@ -178,7 +180,7 @@ class FastMCP(Generic[LifespanResultT]): async def call_tool( self, name: str, arguments: dict[str, Any] - ) -> Sequence[TextContent | ImageContent | EmbeddedResource]: + ) -> list[TextContent | ImageContent | EmbeddedResource]: """Call a tool by name with arguments.""" context = self.get_context() result = await self._tool_manager.call_tool(name, arguments, context=context) diff --git a/tests/server/test_openapi.py b/tests/server/test_openapi.py index 35e9e9ab3..a7fe9a685 100644 --- a/tests/server/test_openapi.py +++ b/tests/server/test_openapi.py @@ -36,17 +36,17 @@ def users_db() -> dict[int, User]: def fastapi_app(users_db: dict[int, User]) -> FastAPI: app = FastAPI(title="FastAPI App") - @app.get("/users") + @app.get("/users", tags=["users", "list"]) async def get_users() -> list[User]: """Get all users.""" return sorted(users_db.values(), key=lambda x: x.id) - @app.get("/users/{user_id}") + @app.get("/users/{user_id}", tags=["users", "detail"]) async def get_user(user_id: int) -> User | None: """Get a user by ID.""" return users_db.get(user_id) - @app.post("/users") + @app.post("/users", tags=["users", "create"]) async def create_user(user: UserCreate) -> User: """Create a new user.""" user_id = max(users_db.keys()) + 1 @@ -54,7 +54,7 @@ def fastapi_app(users_db: dict[int, User]) -> FastAPI: users_db[user_id] = new_user return new_user - @app.patch("/users/{user_id}/name") + @app.patch("/users/{user_id}/name", tags=["users", "update"]) async def update_user_name(user_id: int, name: str) -> User: """Update a user's name.""" user = users_db.get(user_id) @@ -258,3 +258,98 @@ class TestPrompts: """ prompts = await fastmcp_server.list_prompts() assert len(prompts) == 0 + + +class TestTagTransfer: + """Tests for transferring tags from OpenAPI to MCP objects.""" + + async def test_tags_transferred_to_tools(self, fastmcp_server: FastMCPOpenAPI): + """Test that tags from OpenAPI routes are correctly transferred to Tools.""" + # Get internal tools directly (not the public API which returns MCP.Content) + tools = fastmcp_server._tool_manager.list_tools() + + # Find the create_user and update_user_name tools + create_user_tool = next( + (t for t in tools if t.name == "create_user_users_post"), None + ) + update_user_tool = next( + ( + t + for t in tools + if t.name == "update_user_name_users__user_id__name_patch" + ), + None, + ) + + assert create_user_tool is not None + assert update_user_tool is not None + + # Check that tags from OpenAPI routes were transferred to the Tool objects + assert "users" in create_user_tool.tags + assert "create" in create_user_tool.tags + assert len(create_user_tool.tags) == 2 + + assert "users" in update_user_tool.tags + assert "update" in update_user_tool.tags + assert len(update_user_tool.tags) == 2 + + async def test_tags_transferred_to_resources(self, fastmcp_server: FastMCPOpenAPI): + """Test that tags from OpenAPI routes are correctly transferred to Resources.""" + # Get internal resources directly + resources = fastmcp_server._resource_manager.list_resources() + + # Find the get_users resource + get_users_resource = next( + (r for r in resources if r.name == "get_users_users_get"), None + ) + + assert get_users_resource is not None + + # Check that tags from OpenAPI routes were transferred to the Resource object + assert "users" in get_users_resource.tags + assert "list" in get_users_resource.tags + assert len(get_users_resource.tags) == 2 + + async def test_tags_transferred_to_resource_templates( + self, fastmcp_server: FastMCPOpenAPI + ): + """Test that tags from OpenAPI routes are correctly transferred to ResourceTemplates.""" + # Get internal resource templates directly + templates = fastmcp_server._resource_manager.list_templates() + + # Find the get_user template + get_user_template = next( + (t for t in templates if t.name == "get_user_users__user_id__get"), None + ) + + assert get_user_template is not None + + # Check that tags from OpenAPI routes were transferred to the ResourceTemplate object + assert "users" in get_user_template.tags + assert "detail" in get_user_template.tags + assert len(get_user_template.tags) == 2 + + async def test_tags_preserved_in_resources_created_from_templates( + self, fastmcp_server: FastMCPOpenAPI + ): + """Test that tags are preserved when creating resources from templates.""" + # Get internal resource templates directly + templates = fastmcp_server._resource_manager.list_templates() + + # Find the get_user template + get_user_template = next( + (t for t in templates if t.name == "get_user_users__user_id__get"), None + ) + + assert get_user_template is not None + + # Manually create a resource from template + params = {"user_id": 1} + resource = await get_user_template.create_resource( + "resource://openapi/get_user_users__user_id__get/1", params + ) + + # Verify tags are preserved from template to resource + assert "users" in resource.tags + assert "detail" in resource.tags + assert len(resource.tags) == 2 diff --git a/tests/server/test_server.py b/tests/server/test_server.py index 698c4540d..55e35ccff 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -5,9 +5,6 @@ from typing import TYPE_CHECKING import pytest from mcp.shared.exceptions import McpError -from mcp.shared.memory import ( - create_connected_server_and_client_session as client_session, -) from mcp.types import ( BlobResourceContents, ImageContent, @@ -16,7 +13,8 @@ from mcp.types import ( ) from pydantic import AnyUrl, Field -from fastmcp import Context, FastMCP +from fastmcp import Client, Context, FastMCP +from fastmcp.exceptions import ToolError from fastmcp.prompts.prompt import EmbeddedResource, Message, UserMessage from fastmcp.resources import FileResource, FunctionResource from fastmcp.utilities.types import Image @@ -25,7 +23,7 @@ if TYPE_CHECKING: from fastmcp import Context -class TestServer: +class TestCreateServer: async def test_create_server(self): mcp = FastMCP(instructions="Server instructions") assert mcp.name == "FastMCP" @@ -43,18 +41,18 @@ class TestServer: def hello_world(name: str = "世界") -> str: return f"¡Hola, {name}! 👋" - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: tools = await client.list_tools() - assert len(tools.tools) == 1 - tool = tools.tools[0] + assert len(tools) == 1 + tool = tools[0] assert tool.description is not None assert "🌟" in tool.description assert "漢字" in tool.description assert "🎉" in tool.description result = await client.call_tool("hello_world", {}) - assert len(result.content) == 1 - content = result.content[0] + assert len(result) == 1 + content = result[0] assert isinstance(content, TextContent) assert "¡Hola, 世界! 👋" == content.text @@ -97,171 +95,135 @@ class TestServer: return f"Data: {x}" -def tool_fn(x: int, y: int) -> int: - return x + y +@pytest.fixture +def tool_server(): + mcp = FastMCP() + @mcp.tool() + def add(x: int, y: int) -> int: + return x + y -def tool_fn_list() -> list[str | int]: - return ["x", 2] + @mcp.tool() + def list_tool() -> list[str | int]: + return ["x", 2] + @mcp.tool() + def error_tool() -> None: + raise ValueError("Test error") -def error_tool_fn() -> None: - raise ValueError("Test error") + @mcp.tool() + def image_tool(path: str) -> Image: + return Image(path) + @mcp.tool() + def mixed_content_tool() -> list[TextContent | ImageContent]: + return [ + TextContent(type="text", text="Hello"), + ImageContent(type="image", data="abc", mimeType="image/png"), + ] -def image_tool_fn(path: str) -> Image: - return Image(path) + @mcp.tool() + def mixed_list_fn(image_path: str) -> list: + return [ + "text message", + Image(image_path), + {"key": "value"}, + TextContent(type="text", text="direct content"), + ] - -def mixed_content_tool_fn() -> list[TextContent | ImageContent]: - return [ - TextContent(type="text", text="Hello"), - ImageContent(type="image", data="abc", mimeType="image/png"), - ] + return mcp class TestServerTools: - async def test_add_tool(self): - mcp = FastMCP() - mcp.add_tool(tool_fn) - mcp.add_tool(tool_fn) - assert len(mcp._tool_manager.list_tools()) == 1 + async def test_add_tool_exists(self, tool_server: FastMCP): + assert "add" in [t.name for t in await tool_server.list_tools()] - async def test_list_tools(self): - mcp = FastMCP() - mcp.add_tool(tool_fn) - async with client_session(mcp._mcp_server) as client: - tools = await client.list_tools() - assert len(tools.tools) == 1 + async def test_list_tools(self, tool_server: FastMCP): + assert len(await tool_server.list_tools()) == 6 - async def test_call_tool(self): - mcp = FastMCP() - mcp.add_tool(tool_fn) - async with client_session(mcp._mcp_server) as client: - result = await client.call_tool("my_tool", {"arg1": "value"}) - assert not hasattr(result, "error") - assert len(result.content) > 0 + async def test_call_tool(self, tool_server: FastMCP): + result = await tool_server.call_tool("add", {"x": 1, "y": 2}) + assert isinstance(result[0], TextContent) + assert result[0].text == "3" - async def test_tool_exception_handling(self): - mcp = FastMCP() - mcp.add_tool(error_tool_fn) - async with client_session(mcp._mcp_server) as client: - result = await client.call_tool("error_tool_fn", {}) - assert len(result.content) == 1 - content = result.content[0] - assert isinstance(content, TextContent) - assert "Test error" in content.text - assert result.isError is True + async def test_call_tool_as_client(self, tool_server: FastMCP): + async with Client(tool_server) as client: + result = await client.call_tool("add", {"x": 1, "y": 2}) + assert isinstance(result[0], TextContent) + assert result[0].text == "3" - async def test_tool_error_handling(self): - mcp = FastMCP() - mcp.add_tool(error_tool_fn) - async with client_session(mcp._mcp_server) as client: - result = await client.call_tool("error_tool_fn", {}) - assert len(result.content) == 1 - content = result.content[0] - assert isinstance(content, TextContent) - assert "Test error" in content.text - assert result.isError is True + async def test_call_tool_error(self, tool_server: FastMCP): + with pytest.raises(ToolError): + await tool_server.call_tool("error_tool", {}) - async def test_tool_error_details(self): - """Test that exception details are properly formatted in the response""" - mcp = FastMCP() - mcp.add_tool(error_tool_fn) - async with client_session(mcp._mcp_server) as client: - result = await client.call_tool("error_tool_fn", {}) - content = result.content[0] - assert isinstance(content, TextContent) - assert isinstance(content.text, str) - assert "Test error" in content.text - assert result.isError is True + async def test_call_tool_error_as_client(self, tool_server: FastMCP): + async with Client(tool_server) as client: + with pytest.raises(Exception): + await client.call_tool("error_tool", {}) - async def test_tool_return_value_conversion(self): - mcp = FastMCP() - mcp.add_tool(tool_fn) - async with client_session(mcp._mcp_server) as client: - result = await client.call_tool("tool_fn", {"x": 1, "y": 2}) - assert len(result.content) == 1 - content = result.content[0] - assert isinstance(content, TextContent) - assert content.text == "3" + async def test_call_tool_error_as_client_raw(self, tool_server: FastMCP): + async with Client(tool_server) as client: + result = await client.call_tool("error_tool", {}, _return_raw_result=True) + assert result.isError + assert isinstance(result.content[0], TextContent) + assert "Test error" in result.content[0].text - async def test_tool_returns_list(self): - mcp = FastMCP() - mcp.add_tool(tool_fn_list) - async with client_session(mcp._mcp_server) as client: - result = await client.call_tool("tool_fn_list", {}) - assert len(result.content) == 1 - content = result.content[0] - assert isinstance(content, TextContent) - assert json.loads(content.text) == ["x", 2] + async def test_tool_returns_list(self, tool_server: FastMCP): + result = await tool_server.call_tool("list_tool", {}) + assert isinstance(result[0], TextContent) + assert result[0].text == '["x", 2]' - async def test_tool_image_helper(self, tmp_path: Path): + async def test_tool_image_helper(self, tool_server: FastMCP, tmp_path: Path): # Create a test image image_path = tmp_path / "test.png" image_path.write_bytes(b"fake png data") - mcp = FastMCP() - mcp.add_tool(image_tool_fn) - async with client_session(mcp._mcp_server) as client: - result = await client.call_tool("image_tool_fn", {"path": str(image_path)}) - assert len(result.content) == 1 - content = result.content[0] - assert isinstance(content, ImageContent) - assert content.type == "image" - assert content.mimeType == "image/png" - # Verify base64 encoding - decoded = base64.b64decode(content.data) - assert decoded == b"fake png data" + result = await tool_server.call_tool("image_tool", {"path": str(image_path)}) + content = result[0] + assert isinstance(content, ImageContent) + assert content.type == "image" + assert content.mimeType == "image/png" + # Verify base64 encoding + decoded = base64.b64decode(content.data) + assert decoded == b"fake png data" - async def test_tool_mixed_content(self): - mcp = FastMCP() - mcp.add_tool(mixed_content_tool_fn) - async with client_session(mcp._mcp_server) as client: - result = await client.call_tool("mixed_content_tool_fn", {}) + async def test_tool_mixed_content(self, tool_server: FastMCP): + result = await tool_server.call_tool("mixed_content_tool", {}) + assert len(result) == 2 + content1 = result[0] + content2 = result[1] + assert isinstance(content1, TextContent) + assert content1.text == "Hello" + assert isinstance(content2, ImageContent) + assert content2.mimeType == "image/png" + assert content2.data == "abc" - assert len(result.content) == 2 - content1 = result.content[0] - content2 = result.content[1] - assert isinstance(content1, TextContent) - assert content1.text == "Hello" - assert isinstance(content2, ImageContent) - assert content2.mimeType == "image/png" - assert content2.data == "abc" - - async def test_tool_mixed_list_with_image(self, tmp_path: Path): + async def test_tool_mixed_list_with_image( + self, tool_server: FastMCP, tmp_path: Path + ): """Test that lists containing Image objects and other types are handled correctly. Note that the non-MCP content will be grouped together.""" # Create a test image image_path = tmp_path / "test.png" image_path.write_bytes(b"test image data") - def mixed_list_fn() -> list: - return [ - "text message", - Image(image_path), - {"key": "value"}, - TextContent(type="text", text="direct content"), - ] - - mcp = FastMCP() - mcp.add_tool(mixed_list_fn) - async with client_session(mcp._mcp_server) as client: - result = await client.call_tool("mixed_list_fn", {}) - assert len(result.content) == 3 - # Check text conversion - content1 = result.content[0] - assert isinstance(content1, TextContent) - assert json.loads(content1.text) == ["text message", {"key": "value"}] - # Check image conversion - content2 = result.content[1] - assert isinstance(content2, ImageContent) - assert content2.mimeType == "image/png" - assert base64.b64decode(content2.data) == b"test image data" - # Check direct TextContent - content3 = result.content[2] - assert isinstance(content3, TextContent) - assert content3.text == "direct content" + result = await tool_server.call_tool( + "mixed_list_fn", {"image_path": str(image_path)} + ) + assert len(result) == 3 + # Check text conversion + content1 = result[0] + assert isinstance(content1, TextContent) + assert json.loads(content1.text) == ["text message", {"key": "value"}] + # Check image conversion + content2 = result[1] + assert isinstance(content2, ImageContent) + assert content2.mimeType == "image/png" + assert base64.b64decode(content2.data) == b"test image data" + # Check direct TextContent + content3 = result[2] + assert isinstance(content3, TextContent) + assert content3.text == "direct content" async def test_parameter_descriptions(self): mcp = FastMCP("Test Server") @@ -298,10 +260,10 @@ class TestServerResources: ) mcp.add_resource(resource) - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.read_resource(AnyUrl("resource://test")) - assert isinstance(result.contents[0], TextResourceContents) - assert result.contents[0].text == "Hello, world!" + assert isinstance(result[0], TextResourceContents) + assert result[0].text == "Hello, world!" async def test_binary_resource(self): mcp = FastMCP() @@ -317,10 +279,10 @@ class TestServerResources: ) mcp.add_resource(resource) - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.read_resource(AnyUrl("resource://binary")) - assert isinstance(result.contents[0], BlobResourceContents) - assert result.contents[0].blob == base64.b64encode(b"Binary data").decode() + assert isinstance(result[0], BlobResourceContents) + assert result[0].blob == base64.b64encode(b"Binary data").decode() async def test_file_resource_text(self, tmp_path: Path): mcp = FastMCP() @@ -334,10 +296,10 @@ class TestServerResources: ) mcp.add_resource(resource) - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.read_resource(AnyUrl("file://test.txt")) - assert isinstance(result.contents[0], TextResourceContents) - assert result.contents[0].text == "Hello from file!" + assert isinstance(result[0], TextResourceContents) + assert result[0].text == "Hello from file!" async def test_file_resource_binary(self, tmp_path: Path): mcp = FastMCP() @@ -354,13 +316,10 @@ class TestServerResources: ) mcp.add_resource(resource) - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.read_resource(AnyUrl("file://test.bin")) - assert isinstance(result.contents[0], BlobResourceContents) - assert ( - result.contents[0].blob - == base64.b64encode(b"Binary file data").decode() - ) + assert isinstance(result[0], BlobResourceContents) + assert result[0].blob == base64.b64encode(b"Binary file data").decode() class TestServerResourceTemplates: @@ -401,10 +360,10 @@ class TestServerResourceTemplates: def get_data(name: str) -> str: return f"Data for {name}" - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.read_resource(AnyUrl("resource://test/data")) - assert isinstance(result.contents[0], TextResourceContents) - assert result.contents[0].text == "Data for test" + assert isinstance(result[0], TextResourceContents) + assert result[0].text == "Data for test" async def test_resource_mismatched_params(self): """Test that mismatched parameters raise an error""" @@ -424,12 +383,12 @@ class TestServerResourceTemplates: def get_data(org: str, repo: str) -> str: return f"Data for {org}/{repo}" - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.read_resource( AnyUrl("resource://cursor/fastmcp/data") ) - assert isinstance(result.contents[0], TextResourceContents) - assert result.contents[0].text == "Data for cursor/fastmcp" + assert isinstance(result[0], TextResourceContents) + assert result[0].text == "Data for cursor/fastmcp" async def test_resource_multiple_mismatched_params(self): """Test that mismatched parameters raise an error""" @@ -448,10 +407,10 @@ class TestServerResourceTemplates: def get_static_data() -> str: return "Static data" - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.read_resource(AnyUrl("resource://static")) - assert isinstance(result.contents[0], TextResourceContents) - assert result.contents[0].text == "Static data" + assert isinstance(result[0], TextResourceContents) + assert result[0].text == "Static data" async def test_template_to_resource_conversion(self): """Test that templates are properly converted to resources when accessed""" @@ -494,10 +453,10 @@ class TestContextInjection: return f"Request {ctx.request_id}: {x}" mcp.add_tool(tool_with_context) - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.call_tool("tool_with_context", {"x": 42}) - assert len(result.content) == 1 - content = result.content[0] + assert len(result) == 1 + content = result[0] assert isinstance(content, TextContent) assert "Request" in content.text assert "42" in content.text @@ -511,10 +470,10 @@ class TestContextInjection: return f"Async request {ctx.request_id}: {x}" mcp.add_tool(async_tool) - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.call_tool("async_tool", {"x": 42}) - assert len(result.content) == 1 - content = result.content[0] + assert len(result) == 1 + content = result[0] assert isinstance(content, TextContent) assert "Async request" in content.text assert "42" in content.text @@ -537,10 +496,10 @@ class TestContextInjection: mcp.add_tool(logging_tool) with patch("mcp.server.session.ServerSession.send_log_message") as mock_log: - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.call_tool("logging_tool", {"msg": "test"}) - assert len(result.content) == 1 - content = result.content[0] + assert len(result) == 1 + content = result[0] assert isinstance(content, TextContent) assert "Logged messages for test" in content.text @@ -564,10 +523,10 @@ class TestContextInjection: return x * 2 mcp.add_tool(no_context) - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.call_tool("no_context", {"x": 21}) - assert len(result.content) == 1 - content = result.content[0] + assert len(result) == 1 + content = result[0] assert isinstance(content, TextContent) assert content.text == "42" @@ -587,10 +546,10 @@ class TestContextInjection: r = r_list[0] return f"Read resource: {r.content} with mime type {r.mime_type}" - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.call_tool("tool_with_resource", {}) - assert len(result.content) == 1 - content = result.content[0] + assert len(result) == 1 + content = result[0] assert isinstance(content, TextContent) assert "Read resource: resource data" in content.text @@ -661,11 +620,11 @@ class TestServerPrompts: def fn(name: str, optional: str = "default") -> str: return f"Hello, {name}!" - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.list_prompts() - assert result.prompts is not None - assert len(result.prompts) == 1 - prompt = result.prompts[0] + assert result is not None + assert len(result) == 1 + prompt = result[0] assert prompt.name == "fn" assert prompt.arguments is not None assert len(prompt.arguments) == 2 @@ -682,7 +641,7 @@ class TestServerPrompts: def fn(name: str) -> str: return f"Hello, {name}!" - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.get_prompt("fn", {"name": "World"}) assert len(result.messages) == 1 message = result.messages[0] @@ -708,12 +667,10 @@ class TestServerPrompts: ) ) - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: result = await client.get_prompt("fn") - assert len(result.messages) == 1 - message = result.messages[0] - assert message.role == "user" - content = message.content + assert result.messages[0].role == "user" + content = result.messages[0].content assert isinstance(content, EmbeddedResource) resource = content.resource assert isinstance(resource, TextResourceContents) @@ -723,7 +680,7 @@ class TestServerPrompts: async def test_get_unknown_prompt(self): """Test error when getting unknown prompt.""" mcp = FastMCP() - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: with pytest.raises(McpError, match="Unknown prompt"): await client.get_prompt("unknown") @@ -735,7 +692,7 @@ class TestServerPrompts: def prompt_fn(name: str) -> str: return f"Hello, {name}!" - async with client_session(mcp._mcp_server) as client: + async with Client(mcp) as client: with pytest.raises(McpError, match="Missing required arguments"): await client.get_prompt("prompt_fn") diff --git a/tests/utilities/openapi/test_openapi.py b/tests/utilities/openapi/test_openapi.py index 410fef210..83cec52b3 100644 --- a/tests/utilities/openapi/test_openapi.py +++ b/tests/utilities/openapi/test_openapi.py @@ -460,6 +460,63 @@ def test_petstore_required_fields_resolution(parsed_petstore_routes): assert json_schema.get("required") == ["id", "name"] +def test_tags_parsing_in_petstore_routes(parsed_petstore_routes): + """Test that tags are correctly parsed from the OpenAPI schema.""" + # All petstore routes should have the "pets" tag + for route in parsed_petstore_routes: + assert "pets" in route.tags, ( + f"Route {route.method} {route.path} is missing 'pets' tag" + ) + + +def test_tag_list_structure(parsed_petstore_routes): + """Test that tags are stored as a list of strings.""" + for route in parsed_petstore_routes: + assert isinstance(route.tags, list), "Tags should be stored as a list" + for tag in route.tags: + assert isinstance(tag, str), "Each tag should be a string" + + +def test_empty_tags_handling(bookstore_schema): + """Test that routes with no tags are handled correctly with empty lists.""" + # Modify a route to remove tags + if "tags" in bookstore_schema["paths"]["/books"]["get"]: + del bookstore_schema["paths"]["/books"]["get"]["tags"] + + # Parse the modified schema + routes = parse_openapi_to_http_routes(bookstore_schema) + + # Find the GET /books route + get_books = next( + (r for r in routes if r.method == "GET" and r.path == "/books"), None + ) + assert get_books is not None + + # Should have an empty list, not None + assert get_books.tags == [], "Routes without tags should have empty tag lists" + + +def test_multiple_tags_preserved(bookstore_schema): + """Test that multiple tags are preserved during parsing.""" + # Add multiple tags to a route + bookstore_schema["paths"]["/books"]["get"]["tags"] = ["books", "catalog", "api"] + + # Parse the modified schema + routes = parse_openapi_to_http_routes(bookstore_schema) + + # Find the GET /books route + get_books = next( + (r for r in routes if r.method == "GET" and r.path == "/books"), None + ) + assert get_books is not None + + # Should have all tags + assert "books" in get_books.tags + assert "catalog" in get_books.tags + assert "api" in get_books.tags + assert len(get_books.tags) == 3 + + # --- Tests for BookStore schema --- # diff --git a/tests/utilities/openapi/test_openapi_fastapi.py b/tests/utilities/openapi/test_openapi_fastapi.py index b7da748cc..94d0afe6e 100644 --- a/tests/utilities/openapi/test_openapi_fastapi.py +++ b/tests/utilities/openapi/test_openapi_fastapi.py @@ -432,3 +432,91 @@ def test_token_dependency_handling(route_map): token_headers = [p for p in header_params if p.name == "x-token"] assert len(token_headers) == 1, f"Expected x-token header in {op_id}" assert token_headers[0].required is True + + +# --- Additional Tag-related Tests --- # + + +def test_all_routes_have_tags(parsed_routes): + """Test that all routes have a non-empty tags list.""" + for route in parsed_routes: + assert hasattr(route, "tags"), f"Route {route.path} should have tags attribute" + assert route.tags is not None, f"Route {route.path} tags should not be None" + # FastAPI adds tags to all routes in our test fixture + assert len(route.tags) > 0, f"Route {route.path} should have at least one tag" + + +def test_tag_consistency_across_related_endpoints(route_map): + """Test that related endpoints have consistent tags.""" + # All item endpoints should have the "items" tag + item_endpoints = [ + "list_items", + "create_item", + "get_item", + "update_item", + "delete_item", + ] + for endpoint in item_endpoints: + assert "items" in route_map[endpoint].tags, ( + f"Endpoint {endpoint} should have 'items' tag" + ) + + # Tag-related endpoints should have both "items" and "tags" tags + tag_endpoints = ["update_item_tags", "get_item_tag"] + for endpoint in tag_endpoints: + assert "items" in route_map[endpoint].tags, ( + f"Endpoint {endpoint} should have 'items' tag" + ) + assert "tags" in route_map[endpoint].tags, ( + f"Endpoint {endpoint} should have 'tags' tag" + ) + + +def test_tag_order_preservation(fastapi_server): + """Test that tag order is preserved in the parsed routes.""" + + # Add a new endpoint with specifically ordered tags + @fastapi_server.get( + "/test-tag-order", + tags=["first", "second", "third"], + operation_id="test_tag_order", + ) + async def test_tag_order(): + return {"result": "testing tag order"} + + # Get the updated schema and parse routes + routes = parse_openapi_to_http_routes(fastapi_server.openapi()) + + # Find our test route + test_route = next((r for r in routes if r.path == "/test-tag-order"), None) + assert test_route is not None + + # Check tag order is preserved + assert test_route.tags == ["first", "second", "third"], ( + "Tag order should be preserved" + ) + + +def test_duplicate_tags_handling(fastapi_server): + """Test handling of duplicate tags in the OpenAPI schema.""" + + # Add an endpoint with duplicate tags + @fastapi_server.get( + "/test-duplicate-tags", + tags=["duplicate", "items", "duplicate"], + operation_id="test_duplicate_tags", + ) + async def test_duplicate_tags(): + return {"result": "testing duplicate tags"} + + # Get the updated schema and parse routes + routes = parse_openapi_to_http_routes(fastapi_server.openapi()) + + # Find our test route + test_route = next((r for r in routes if r.path == "/test-duplicate-tags"), None) + assert test_route is not None + + # Check that duplicate tags are preserved (FastAPI might deduplicate) + # We'll test both possibilities to be safe + assert "duplicate" in test_route.tags, "Tag 'duplicate' should be present" + assert "items" in test_route.tags, "Tag 'items' should be present"