mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-22 21:44:18 +02:00
Merge branch 'main' into docs
This commit is contained in:
commit
bae82b491b
6 changed files with 426 additions and 215 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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 --- #
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue