Merge branch 'main' into docs

This commit is contained in:
Jeremiah Lowin 2025-04-12 15:04:36 -04:00
commit bae82b491b
6 changed files with 426 additions and 215 deletions

View file

@ -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:

View file

@ -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)

View file

@ -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

View file

@ -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")

View file

@ -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 --- #

View file

@ -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"