Spaces:
Running
Running
Merge pull request #116 from jlowin/list-bug
Browse files- src/fastmcp/server/server.py +23 -4
- tests/server/test_server.py +21 -51
src/fastmcp/server/server.py
CHANGED
|
@@ -8,7 +8,6 @@ from contextlib import (
|
|
| 8 |
AbstractAsyncContextManager,
|
| 9 |
asynccontextmanager,
|
| 10 |
)
|
| 11 |
-
from itertools import chain
|
| 12 |
from typing import TYPE_CHECKING, Any, Generic, Literal
|
| 13 |
|
| 14 |
import anyio
|
|
@@ -618,7 +617,8 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 618 |
|
| 619 |
def _convert_to_content(
|
| 620 |
result: Any,
|
| 621 |
-
|
|
|
|
| 622 |
"""Convert a result to a sequence of content objects."""
|
| 623 |
if result is None:
|
| 624 |
return []
|
|
@@ -629,8 +629,27 @@ def _convert_to_content(
|
|
| 629 |
if isinstance(result, Image):
|
| 630 |
return [result.to_image_content()]
|
| 631 |
|
| 632 |
-
if isinstance(result, list | tuple):
|
| 633 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 634 |
|
| 635 |
if not isinstance(result, str):
|
| 636 |
try:
|
|
|
|
| 8 |
AbstractAsyncContextManager,
|
| 9 |
asynccontextmanager,
|
| 10 |
)
|
|
|
|
| 11 |
from typing import TYPE_CHECKING, Any, Generic, Literal
|
| 12 |
|
| 13 |
import anyio
|
|
|
|
| 617 |
|
| 618 |
def _convert_to_content(
|
| 619 |
result: Any,
|
| 620 |
+
_process_as_single_item: bool = False,
|
| 621 |
+
) -> list[TextContent | ImageContent | EmbeddedResource]:
|
| 622 |
"""Convert a result to a sequence of content objects."""
|
| 623 |
if result is None:
|
| 624 |
return []
|
|
|
|
| 629 |
if isinstance(result, Image):
|
| 630 |
return [result.to_image_content()]
|
| 631 |
|
| 632 |
+
if isinstance(result, list | tuple) and not _process_as_single_item:
|
| 633 |
+
# if the result is a list, then it could either be a list of MCP types,
|
| 634 |
+
# or a "regular" list that the tool is returning, or a mix of both.
|
| 635 |
+
#
|
| 636 |
+
# so we extract all the MCP types / images and convert them as individual content elements,
|
| 637 |
+
# and aggregate the rest as a single content element
|
| 638 |
+
|
| 639 |
+
mcp_types = []
|
| 640 |
+
other_content = []
|
| 641 |
+
|
| 642 |
+
for item in result:
|
| 643 |
+
if isinstance(item, (TextContent, ImageContent, EmbeddedResource, Image)):
|
| 644 |
+
mcp_types.append(_convert_to_content(item)[0])
|
| 645 |
+
else:
|
| 646 |
+
other_content.append(item)
|
| 647 |
+
if other_content:
|
| 648 |
+
other_content = _convert_to_content(
|
| 649 |
+
other_content, _process_as_single_item=True
|
| 650 |
+
)
|
| 651 |
+
|
| 652 |
+
return other_content + mcp_types
|
| 653 |
|
| 654 |
if not isinstance(result, str):
|
| 655 |
try:
|
tests/server/test_server.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import base64
|
|
|
|
| 2 |
from pathlib import Path
|
| 3 |
from typing import TYPE_CHECKING
|
| 4 |
|
|
@@ -25,13 +26,11 @@ if TYPE_CHECKING:
|
|
| 25 |
|
| 26 |
|
| 27 |
class TestServer:
|
| 28 |
-
@pytest.mark.anyio
|
| 29 |
async def test_create_server(self):
|
| 30 |
mcp = FastMCP(instructions="Server instructions")
|
| 31 |
assert mcp.name == "FastMCP"
|
| 32 |
assert mcp.instructions == "Server instructions"
|
| 33 |
|
| 34 |
-
@pytest.mark.anyio
|
| 35 |
async def test_non_ascii_description(self):
|
| 36 |
"""Test that FastMCP handles non-ASCII characters in descriptions correctly"""
|
| 37 |
mcp = FastMCP()
|
|
@@ -59,7 +58,6 @@ class TestServer:
|
|
| 59 |
assert isinstance(content, TextContent)
|
| 60 |
assert "¡Hola, 世界! 👋" == content.text
|
| 61 |
|
| 62 |
-
@pytest.mark.anyio
|
| 63 |
async def test_add_tool_decorator(self):
|
| 64 |
mcp = FastMCP()
|
| 65 |
|
|
@@ -69,7 +67,6 @@ class TestServer:
|
|
| 69 |
|
| 70 |
assert len(mcp._tool_manager.list_tools()) == 1
|
| 71 |
|
| 72 |
-
@pytest.mark.anyio
|
| 73 |
async def test_add_tool_decorator_incorrect_usage(self):
|
| 74 |
mcp = FastMCP()
|
| 75 |
|
|
@@ -79,7 +76,6 @@ class TestServer:
|
|
| 79 |
def add(x: int, y: int) -> int:
|
| 80 |
return x + y
|
| 81 |
|
| 82 |
-
@pytest.mark.anyio
|
| 83 |
async def test_add_resource_decorator(self):
|
| 84 |
mcp = FastMCP()
|
| 85 |
|
|
@@ -89,7 +85,6 @@ class TestServer:
|
|
| 89 |
|
| 90 |
assert len(mcp._resource_manager._templates) == 1
|
| 91 |
|
| 92 |
-
@pytest.mark.anyio
|
| 93 |
async def test_add_resource_decorator_incorrect_usage(self):
|
| 94 |
mcp = FastMCP()
|
| 95 |
|
|
@@ -106,6 +101,10 @@ def tool_fn(x: int, y: int) -> int:
|
|
| 106 |
return x + y
|
| 107 |
|
| 108 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
def error_tool_fn() -> None:
|
| 110 |
raise ValueError("Test error")
|
| 111 |
|
|
@@ -122,14 +121,12 @@ def mixed_content_tool_fn() -> list[TextContent | ImageContent]:
|
|
| 122 |
|
| 123 |
|
| 124 |
class TestServerTools:
|
| 125 |
-
@pytest.mark.anyio
|
| 126 |
async def test_add_tool(self):
|
| 127 |
mcp = FastMCP()
|
| 128 |
mcp.add_tool(tool_fn)
|
| 129 |
mcp.add_tool(tool_fn)
|
| 130 |
assert len(mcp._tool_manager.list_tools()) == 1
|
| 131 |
|
| 132 |
-
@pytest.mark.anyio
|
| 133 |
async def test_list_tools(self):
|
| 134 |
mcp = FastMCP()
|
| 135 |
mcp.add_tool(tool_fn)
|
|
@@ -137,7 +134,6 @@ class TestServerTools:
|
|
| 137 |
tools = await client.list_tools()
|
| 138 |
assert len(tools.tools) == 1
|
| 139 |
|
| 140 |
-
@pytest.mark.anyio
|
| 141 |
async def test_call_tool(self):
|
| 142 |
mcp = FastMCP()
|
| 143 |
mcp.add_tool(tool_fn)
|
|
@@ -146,7 +142,6 @@ class TestServerTools:
|
|
| 146 |
assert not hasattr(result, "error")
|
| 147 |
assert len(result.content) > 0
|
| 148 |
|
| 149 |
-
@pytest.mark.anyio
|
| 150 |
async def test_tool_exception_handling(self):
|
| 151 |
mcp = FastMCP()
|
| 152 |
mcp.add_tool(error_tool_fn)
|
|
@@ -158,7 +153,6 @@ class TestServerTools:
|
|
| 158 |
assert "Test error" in content.text
|
| 159 |
assert result.isError is True
|
| 160 |
|
| 161 |
-
@pytest.mark.anyio
|
| 162 |
async def test_tool_error_handling(self):
|
| 163 |
mcp = FastMCP()
|
| 164 |
mcp.add_tool(error_tool_fn)
|
|
@@ -170,7 +164,6 @@ class TestServerTools:
|
|
| 170 |
assert "Test error" in content.text
|
| 171 |
assert result.isError is True
|
| 172 |
|
| 173 |
-
@pytest.mark.anyio
|
| 174 |
async def test_tool_error_details(self):
|
| 175 |
"""Test that exception details are properly formatted in the response"""
|
| 176 |
mcp = FastMCP()
|
|
@@ -183,7 +176,6 @@ class TestServerTools:
|
|
| 183 |
assert "Test error" in content.text
|
| 184 |
assert result.isError is True
|
| 185 |
|
| 186 |
-
@pytest.mark.anyio
|
| 187 |
async def test_tool_return_value_conversion(self):
|
| 188 |
mcp = FastMCP()
|
| 189 |
mcp.add_tool(tool_fn)
|
|
@@ -194,7 +186,16 @@ class TestServerTools:
|
|
| 194 |
assert isinstance(content, TextContent)
|
| 195 |
assert content.text == "3"
|
| 196 |
|
| 197 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
async def test_tool_image_helper(self, tmp_path: Path):
|
| 199 |
# Create a test image
|
| 200 |
image_path = tmp_path / "test.png"
|
|
@@ -213,12 +214,12 @@ class TestServerTools:
|
|
| 213 |
decoded = base64.b64decode(content.data)
|
| 214 |
assert decoded == b"fake png data"
|
| 215 |
|
| 216 |
-
@pytest.mark.anyio
|
| 217 |
async def test_tool_mixed_content(self):
|
| 218 |
mcp = FastMCP()
|
| 219 |
mcp.add_tool(mixed_content_tool_fn)
|
| 220 |
async with client_session(mcp._mcp_server) as client:
|
| 221 |
result = await client.call_tool("mixed_content_tool_fn", {})
|
|
|
|
| 222 |
assert len(result.content) == 2
|
| 223 |
content1 = result.content[0]
|
| 224 |
content2 = result.content[1]
|
|
@@ -228,10 +229,9 @@ class TestServerTools:
|
|
| 228 |
assert content2.mimeType == "image/png"
|
| 229 |
assert content2.data == "abc"
|
| 230 |
|
| 231 |
-
@pytest.mark.anyio
|
| 232 |
async def test_tool_mixed_list_with_image(self, tmp_path: Path):
|
| 233 |
"""Test that lists containing Image objects and other types are handled
|
| 234 |
-
correctly"""
|
| 235 |
# Create a test image
|
| 236 |
image_path = tmp_path / "test.png"
|
| 237 |
image_path.write_bytes(b"test image data")
|
|
@@ -248,24 +248,20 @@ class TestServerTools:
|
|
| 248 |
mcp.add_tool(mixed_list_fn)
|
| 249 |
async with client_session(mcp._mcp_server) as client:
|
| 250 |
result = await client.call_tool("mixed_list_fn", {})
|
| 251 |
-
assert len(result.content) ==
|
| 252 |
# Check text conversion
|
| 253 |
content1 = result.content[0]
|
| 254 |
assert isinstance(content1, TextContent)
|
| 255 |
-
assert content1.text == "text message"
|
| 256 |
# Check image conversion
|
| 257 |
content2 = result.content[1]
|
| 258 |
assert isinstance(content2, ImageContent)
|
| 259 |
assert content2.mimeType == "image/png"
|
| 260 |
assert base64.b64decode(content2.data) == b"test image data"
|
| 261 |
-
# Check
|
| 262 |
content3 = result.content[2]
|
| 263 |
assert isinstance(content3, TextContent)
|
| 264 |
-
assert
|
| 265 |
-
# Check direct TextContent
|
| 266 |
-
content4 = result.content[3]
|
| 267 |
-
assert isinstance(content4, TextContent)
|
| 268 |
-
assert content4.text == "direct content"
|
| 269 |
|
| 270 |
async def test_parameter_descriptions(self):
|
| 271 |
mcp = FastMCP("Test Server")
|
|
@@ -291,7 +287,6 @@ class TestServerTools:
|
|
| 291 |
|
| 292 |
|
| 293 |
class TestServerResources:
|
| 294 |
-
@pytest.mark.anyio
|
| 295 |
async def test_text_resource(self):
|
| 296 |
mcp = FastMCP()
|
| 297 |
|
|
@@ -308,7 +303,6 @@ class TestServerResources:
|
|
| 308 |
assert isinstance(result.contents[0], TextResourceContents)
|
| 309 |
assert result.contents[0].text == "Hello, world!"
|
| 310 |
|
| 311 |
-
@pytest.mark.anyio
|
| 312 |
async def test_binary_resource(self):
|
| 313 |
mcp = FastMCP()
|
| 314 |
|
|
@@ -328,7 +322,6 @@ class TestServerResources:
|
|
| 328 |
assert isinstance(result.contents[0], BlobResourceContents)
|
| 329 |
assert result.contents[0].blob == base64.b64encode(b"Binary data").decode()
|
| 330 |
|
| 331 |
-
@pytest.mark.anyio
|
| 332 |
async def test_file_resource_text(self, tmp_path: Path):
|
| 333 |
mcp = FastMCP()
|
| 334 |
|
|
@@ -346,7 +339,6 @@ class TestServerResources:
|
|
| 346 |
assert isinstance(result.contents[0], TextResourceContents)
|
| 347 |
assert result.contents[0].text == "Hello from file!"
|
| 348 |
|
| 349 |
-
@pytest.mark.anyio
|
| 350 |
async def test_file_resource_binary(self, tmp_path: Path):
|
| 351 |
mcp = FastMCP()
|
| 352 |
|
|
@@ -372,7 +364,6 @@ class TestServerResources:
|
|
| 372 |
|
| 373 |
|
| 374 |
class TestServerResourceTemplates:
|
| 375 |
-
@pytest.mark.anyio
|
| 376 |
async def test_resource_with_params(self):
|
| 377 |
"""Test that a resource with function parameters raises an error if the URI
|
| 378 |
parameters don't match"""
|
|
@@ -384,7 +375,6 @@ class TestServerResourceTemplates:
|
|
| 384 |
def get_data_fn(param: str) -> str:
|
| 385 |
return f"Data: {param}"
|
| 386 |
|
| 387 |
-
@pytest.mark.anyio
|
| 388 |
async def test_resource_with_uri_params(self):
|
| 389 |
"""Test that a resource with URI parameters is automatically a template"""
|
| 390 |
mcp = FastMCP()
|
|
@@ -395,7 +385,6 @@ class TestServerResourceTemplates:
|
|
| 395 |
def get_data() -> str:
|
| 396 |
return "Data"
|
| 397 |
|
| 398 |
-
@pytest.mark.anyio
|
| 399 |
async def test_resource_with_untyped_params(self):
|
| 400 |
"""Test that a resource with untyped parameters raises an error"""
|
| 401 |
mcp = FastMCP()
|
|
@@ -404,7 +393,6 @@ class TestServerResourceTemplates:
|
|
| 404 |
def get_data(param) -> str:
|
| 405 |
return "Data"
|
| 406 |
|
| 407 |
-
@pytest.mark.anyio
|
| 408 |
async def test_resource_matching_params(self):
|
| 409 |
"""Test that a resource with matching URI and function parameters works"""
|
| 410 |
mcp = FastMCP()
|
|
@@ -418,7 +406,6 @@ class TestServerResourceTemplates:
|
|
| 418 |
assert isinstance(result.contents[0], TextResourceContents)
|
| 419 |
assert result.contents[0].text == "Data for test"
|
| 420 |
|
| 421 |
-
@pytest.mark.anyio
|
| 422 |
async def test_resource_mismatched_params(self):
|
| 423 |
"""Test that mismatched parameters raise an error"""
|
| 424 |
mcp = FastMCP()
|
|
@@ -429,7 +416,6 @@ class TestServerResourceTemplates:
|
|
| 429 |
def get_data(user: str) -> str:
|
| 430 |
return f"Data for {user}"
|
| 431 |
|
| 432 |
-
@pytest.mark.anyio
|
| 433 |
async def test_resource_multiple_params(self):
|
| 434 |
"""Test that multiple parameters work correctly"""
|
| 435 |
mcp = FastMCP()
|
|
@@ -445,7 +431,6 @@ class TestServerResourceTemplates:
|
|
| 445 |
assert isinstance(result.contents[0], TextResourceContents)
|
| 446 |
assert result.contents[0].text == "Data for cursor/fastmcp"
|
| 447 |
|
| 448 |
-
@pytest.mark.anyio
|
| 449 |
async def test_resource_multiple_mismatched_params(self):
|
| 450 |
"""Test that mismatched parameters raise an error"""
|
| 451 |
mcp = FastMCP()
|
|
@@ -468,7 +453,6 @@ class TestServerResourceTemplates:
|
|
| 468 |
assert isinstance(result.contents[0], TextResourceContents)
|
| 469 |
assert result.contents[0].text == "Static data"
|
| 470 |
|
| 471 |
-
@pytest.mark.anyio
|
| 472 |
async def test_template_to_resource_conversion(self):
|
| 473 |
"""Test that templates are properly converted to resources when accessed"""
|
| 474 |
mcp = FastMCP()
|
|
@@ -491,7 +475,6 @@ class TestServerResourceTemplates:
|
|
| 491 |
class TestContextInjection:
|
| 492 |
"""Test context injection in tools."""
|
| 493 |
|
| 494 |
-
@pytest.mark.anyio
|
| 495 |
async def test_context_detection(self):
|
| 496 |
"""Test that context parameters are properly detected."""
|
| 497 |
mcp = FastMCP()
|
|
@@ -502,7 +485,6 @@ class TestContextInjection:
|
|
| 502 |
tool = mcp._tool_manager.add_tool(tool_with_context)
|
| 503 |
assert tool.context_kwarg == "ctx"
|
| 504 |
|
| 505 |
-
@pytest.mark.anyio
|
| 506 |
async def test_context_injection(self):
|
| 507 |
"""Test that context is properly injected into tool calls."""
|
| 508 |
mcp = FastMCP()
|
|
@@ -520,7 +502,6 @@ class TestContextInjection:
|
|
| 520 |
assert "Request" in content.text
|
| 521 |
assert "42" in content.text
|
| 522 |
|
| 523 |
-
@pytest.mark.anyio
|
| 524 |
async def test_async_context(self):
|
| 525 |
"""Test that context works in async functions."""
|
| 526 |
mcp = FastMCP()
|
|
@@ -538,7 +519,6 @@ class TestContextInjection:
|
|
| 538 |
assert "Async request" in content.text
|
| 539 |
assert "42" in content.text
|
| 540 |
|
| 541 |
-
@pytest.mark.anyio
|
| 542 |
async def test_context_logging(self):
|
| 543 |
from unittest.mock import patch
|
| 544 |
|
|
@@ -576,7 +556,6 @@ class TestContextInjection:
|
|
| 576 |
level="error", data="Error message", logger=None
|
| 577 |
)
|
| 578 |
|
| 579 |
-
@pytest.mark.anyio
|
| 580 |
async def test_optional_context(self):
|
| 581 |
"""Test that context is optional."""
|
| 582 |
mcp = FastMCP()
|
|
@@ -592,7 +571,6 @@ class TestContextInjection:
|
|
| 592 |
assert isinstance(content, TextContent)
|
| 593 |
assert content.text == "42"
|
| 594 |
|
| 595 |
-
@pytest.mark.anyio
|
| 596 |
async def test_context_resource_access(self):
|
| 597 |
"""Test that context can access resources."""
|
| 598 |
mcp = FastMCP()
|
|
@@ -620,7 +598,6 @@ class TestContextInjection:
|
|
| 620 |
class TestServerPrompts:
|
| 621 |
"""Test prompt functionality in FastMCP server."""
|
| 622 |
|
| 623 |
-
@pytest.mark.anyio
|
| 624 |
async def test_prompt_decorator(self):
|
| 625 |
"""Test that the prompt decorator registers prompts correctly."""
|
| 626 |
mcp = FastMCP()
|
|
@@ -637,7 +614,6 @@ class TestServerPrompts:
|
|
| 637 |
assert isinstance(content[0].content, TextContent)
|
| 638 |
assert content[0].content.text == "Hello, world!"
|
| 639 |
|
| 640 |
-
@pytest.mark.anyio
|
| 641 |
async def test_prompt_decorator_with_name(self):
|
| 642 |
"""Test prompt decorator with custom name."""
|
| 643 |
mcp = FastMCP()
|
|
@@ -653,7 +629,6 @@ class TestServerPrompts:
|
|
| 653 |
assert isinstance(content[0].content, TextContent)
|
| 654 |
assert content[0].content.text == "Hello, world!"
|
| 655 |
|
| 656 |
-
@pytest.mark.anyio
|
| 657 |
async def test_prompt_decorator_with_description(self):
|
| 658 |
"""Test prompt decorator with custom description."""
|
| 659 |
mcp = FastMCP()
|
|
@@ -678,7 +653,6 @@ class TestServerPrompts:
|
|
| 678 |
def fn() -> str:
|
| 679 |
return "Hello, world!"
|
| 680 |
|
| 681 |
-
@pytest.mark.anyio
|
| 682 |
async def test_list_prompts(self):
|
| 683 |
"""Test listing prompts through MCP protocol."""
|
| 684 |
mcp = FastMCP()
|
|
@@ -700,7 +674,6 @@ class TestServerPrompts:
|
|
| 700 |
assert prompt.arguments[1].name == "optional"
|
| 701 |
assert prompt.arguments[1].required is False
|
| 702 |
|
| 703 |
-
@pytest.mark.anyio
|
| 704 |
async def test_get_prompt(self):
|
| 705 |
"""Test getting a prompt through MCP protocol."""
|
| 706 |
mcp = FastMCP()
|
|
@@ -718,7 +691,6 @@ class TestServerPrompts:
|
|
| 718 |
assert isinstance(content, TextContent)
|
| 719 |
assert content.text == "Hello, World!"
|
| 720 |
|
| 721 |
-
@pytest.mark.anyio
|
| 722 |
async def test_get_prompt_with_resource(self):
|
| 723 |
"""Test getting a prompt that returns resource content."""
|
| 724 |
mcp = FastMCP()
|
|
@@ -748,7 +720,6 @@ class TestServerPrompts:
|
|
| 748 |
assert resource.text == "File contents"
|
| 749 |
assert resource.mimeType == "text/plain"
|
| 750 |
|
| 751 |
-
@pytest.mark.anyio
|
| 752 |
async def test_get_unknown_prompt(self):
|
| 753 |
"""Test error when getting unknown prompt."""
|
| 754 |
mcp = FastMCP()
|
|
@@ -756,7 +727,6 @@ class TestServerPrompts:
|
|
| 756 |
with pytest.raises(McpError, match="Unknown prompt"):
|
| 757 |
await client.get_prompt("unknown")
|
| 758 |
|
| 759 |
-
@pytest.mark.anyio
|
| 760 |
async def test_get_prompt_missing_args(self):
|
| 761 |
"""Test error when required arguments are missing."""
|
| 762 |
mcp = FastMCP()
|
|
|
|
| 1 |
import base64
|
| 2 |
+
import json
|
| 3 |
from pathlib import Path
|
| 4 |
from typing import TYPE_CHECKING
|
| 5 |
|
|
|
|
| 26 |
|
| 27 |
|
| 28 |
class TestServer:
|
|
|
|
| 29 |
async def test_create_server(self):
|
| 30 |
mcp = FastMCP(instructions="Server instructions")
|
| 31 |
assert mcp.name == "FastMCP"
|
| 32 |
assert mcp.instructions == "Server instructions"
|
| 33 |
|
|
|
|
| 34 |
async def test_non_ascii_description(self):
|
| 35 |
"""Test that FastMCP handles non-ASCII characters in descriptions correctly"""
|
| 36 |
mcp = FastMCP()
|
|
|
|
| 58 |
assert isinstance(content, TextContent)
|
| 59 |
assert "¡Hola, 世界! 👋" == content.text
|
| 60 |
|
|
|
|
| 61 |
async def test_add_tool_decorator(self):
|
| 62 |
mcp = FastMCP()
|
| 63 |
|
|
|
|
| 67 |
|
| 68 |
assert len(mcp._tool_manager.list_tools()) == 1
|
| 69 |
|
|
|
|
| 70 |
async def test_add_tool_decorator_incorrect_usage(self):
|
| 71 |
mcp = FastMCP()
|
| 72 |
|
|
|
|
| 76 |
def add(x: int, y: int) -> int:
|
| 77 |
return x + y
|
| 78 |
|
|
|
|
| 79 |
async def test_add_resource_decorator(self):
|
| 80 |
mcp = FastMCP()
|
| 81 |
|
|
|
|
| 85 |
|
| 86 |
assert len(mcp._resource_manager._templates) == 1
|
| 87 |
|
|
|
|
| 88 |
async def test_add_resource_decorator_incorrect_usage(self):
|
| 89 |
mcp = FastMCP()
|
| 90 |
|
|
|
|
| 101 |
return x + y
|
| 102 |
|
| 103 |
|
| 104 |
+
def tool_fn_list() -> list[str | int]:
|
| 105 |
+
return ["x", 2]
|
| 106 |
+
|
| 107 |
+
|
| 108 |
def error_tool_fn() -> None:
|
| 109 |
raise ValueError("Test error")
|
| 110 |
|
|
|
|
| 121 |
|
| 122 |
|
| 123 |
class TestServerTools:
|
|
|
|
| 124 |
async def test_add_tool(self):
|
| 125 |
mcp = FastMCP()
|
| 126 |
mcp.add_tool(tool_fn)
|
| 127 |
mcp.add_tool(tool_fn)
|
| 128 |
assert len(mcp._tool_manager.list_tools()) == 1
|
| 129 |
|
|
|
|
| 130 |
async def test_list_tools(self):
|
| 131 |
mcp = FastMCP()
|
| 132 |
mcp.add_tool(tool_fn)
|
|
|
|
| 134 |
tools = await client.list_tools()
|
| 135 |
assert len(tools.tools) == 1
|
| 136 |
|
|
|
|
| 137 |
async def test_call_tool(self):
|
| 138 |
mcp = FastMCP()
|
| 139 |
mcp.add_tool(tool_fn)
|
|
|
|
| 142 |
assert not hasattr(result, "error")
|
| 143 |
assert len(result.content) > 0
|
| 144 |
|
|
|
|
| 145 |
async def test_tool_exception_handling(self):
|
| 146 |
mcp = FastMCP()
|
| 147 |
mcp.add_tool(error_tool_fn)
|
|
|
|
| 153 |
assert "Test error" in content.text
|
| 154 |
assert result.isError is True
|
| 155 |
|
|
|
|
| 156 |
async def test_tool_error_handling(self):
|
| 157 |
mcp = FastMCP()
|
| 158 |
mcp.add_tool(error_tool_fn)
|
|
|
|
| 164 |
assert "Test error" in content.text
|
| 165 |
assert result.isError is True
|
| 166 |
|
|
|
|
| 167 |
async def test_tool_error_details(self):
|
| 168 |
"""Test that exception details are properly formatted in the response"""
|
| 169 |
mcp = FastMCP()
|
|
|
|
| 176 |
assert "Test error" in content.text
|
| 177 |
assert result.isError is True
|
| 178 |
|
|
|
|
| 179 |
async def test_tool_return_value_conversion(self):
|
| 180 |
mcp = FastMCP()
|
| 181 |
mcp.add_tool(tool_fn)
|
|
|
|
| 186 |
assert isinstance(content, TextContent)
|
| 187 |
assert content.text == "3"
|
| 188 |
|
| 189 |
+
async def test_tool_returns_list(self):
|
| 190 |
+
mcp = FastMCP()
|
| 191 |
+
mcp.add_tool(tool_fn_list)
|
| 192 |
+
async with client_session(mcp._mcp_server) as client:
|
| 193 |
+
result = await client.call_tool("tool_fn_list", {})
|
| 194 |
+
assert len(result.content) == 1
|
| 195 |
+
content = result.content[0]
|
| 196 |
+
assert isinstance(content, TextContent)
|
| 197 |
+
assert json.loads(content.text) == ["x", 2]
|
| 198 |
+
|
| 199 |
async def test_tool_image_helper(self, tmp_path: Path):
|
| 200 |
# Create a test image
|
| 201 |
image_path = tmp_path / "test.png"
|
|
|
|
| 214 |
decoded = base64.b64decode(content.data)
|
| 215 |
assert decoded == b"fake png data"
|
| 216 |
|
|
|
|
| 217 |
async def test_tool_mixed_content(self):
|
| 218 |
mcp = FastMCP()
|
| 219 |
mcp.add_tool(mixed_content_tool_fn)
|
| 220 |
async with client_session(mcp._mcp_server) as client:
|
| 221 |
result = await client.call_tool("mixed_content_tool_fn", {})
|
| 222 |
+
|
| 223 |
assert len(result.content) == 2
|
| 224 |
content1 = result.content[0]
|
| 225 |
content2 = result.content[1]
|
|
|
|
| 229 |
assert content2.mimeType == "image/png"
|
| 230 |
assert content2.data == "abc"
|
| 231 |
|
|
|
|
| 232 |
async def test_tool_mixed_list_with_image(self, tmp_path: Path):
|
| 233 |
"""Test that lists containing Image objects and other types are handled
|
| 234 |
+
correctly. Note that the non-MCP content will be grouped together."""
|
| 235 |
# Create a test image
|
| 236 |
image_path = tmp_path / "test.png"
|
| 237 |
image_path.write_bytes(b"test image data")
|
|
|
|
| 248 |
mcp.add_tool(mixed_list_fn)
|
| 249 |
async with client_session(mcp._mcp_server) as client:
|
| 250 |
result = await client.call_tool("mixed_list_fn", {})
|
| 251 |
+
assert len(result.content) == 3
|
| 252 |
# Check text conversion
|
| 253 |
content1 = result.content[0]
|
| 254 |
assert isinstance(content1, TextContent)
|
| 255 |
+
assert json.loads(content1.text) == ["text message", {"key": "value"}]
|
| 256 |
# Check image conversion
|
| 257 |
content2 = result.content[1]
|
| 258 |
assert isinstance(content2, ImageContent)
|
| 259 |
assert content2.mimeType == "image/png"
|
| 260 |
assert base64.b64decode(content2.data) == b"test image data"
|
| 261 |
+
# Check direct TextContent
|
| 262 |
content3 = result.content[2]
|
| 263 |
assert isinstance(content3, TextContent)
|
| 264 |
+
assert content3.text == "direct content"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
|
| 266 |
async def test_parameter_descriptions(self):
|
| 267 |
mcp = FastMCP("Test Server")
|
|
|
|
| 287 |
|
| 288 |
|
| 289 |
class TestServerResources:
|
|
|
|
| 290 |
async def test_text_resource(self):
|
| 291 |
mcp = FastMCP()
|
| 292 |
|
|
|
|
| 303 |
assert isinstance(result.contents[0], TextResourceContents)
|
| 304 |
assert result.contents[0].text == "Hello, world!"
|
| 305 |
|
|
|
|
| 306 |
async def test_binary_resource(self):
|
| 307 |
mcp = FastMCP()
|
| 308 |
|
|
|
|
| 322 |
assert isinstance(result.contents[0], BlobResourceContents)
|
| 323 |
assert result.contents[0].blob == base64.b64encode(b"Binary data").decode()
|
| 324 |
|
|
|
|
| 325 |
async def test_file_resource_text(self, tmp_path: Path):
|
| 326 |
mcp = FastMCP()
|
| 327 |
|
|
|
|
| 339 |
assert isinstance(result.contents[0], TextResourceContents)
|
| 340 |
assert result.contents[0].text == "Hello from file!"
|
| 341 |
|
|
|
|
| 342 |
async def test_file_resource_binary(self, tmp_path: Path):
|
| 343 |
mcp = FastMCP()
|
| 344 |
|
|
|
|
| 364 |
|
| 365 |
|
| 366 |
class TestServerResourceTemplates:
|
|
|
|
| 367 |
async def test_resource_with_params(self):
|
| 368 |
"""Test that a resource with function parameters raises an error if the URI
|
| 369 |
parameters don't match"""
|
|
|
|
| 375 |
def get_data_fn(param: str) -> str:
|
| 376 |
return f"Data: {param}"
|
| 377 |
|
|
|
|
| 378 |
async def test_resource_with_uri_params(self):
|
| 379 |
"""Test that a resource with URI parameters is automatically a template"""
|
| 380 |
mcp = FastMCP()
|
|
|
|
| 385 |
def get_data() -> str:
|
| 386 |
return "Data"
|
| 387 |
|
|
|
|
| 388 |
async def test_resource_with_untyped_params(self):
|
| 389 |
"""Test that a resource with untyped parameters raises an error"""
|
| 390 |
mcp = FastMCP()
|
|
|
|
| 393 |
def get_data(param) -> str:
|
| 394 |
return "Data"
|
| 395 |
|
|
|
|
| 396 |
async def test_resource_matching_params(self):
|
| 397 |
"""Test that a resource with matching URI and function parameters works"""
|
| 398 |
mcp = FastMCP()
|
|
|
|
| 406 |
assert isinstance(result.contents[0], TextResourceContents)
|
| 407 |
assert result.contents[0].text == "Data for test"
|
| 408 |
|
|
|
|
| 409 |
async def test_resource_mismatched_params(self):
|
| 410 |
"""Test that mismatched parameters raise an error"""
|
| 411 |
mcp = FastMCP()
|
|
|
|
| 416 |
def get_data(user: str) -> str:
|
| 417 |
return f"Data for {user}"
|
| 418 |
|
|
|
|
| 419 |
async def test_resource_multiple_params(self):
|
| 420 |
"""Test that multiple parameters work correctly"""
|
| 421 |
mcp = FastMCP()
|
|
|
|
| 431 |
assert isinstance(result.contents[0], TextResourceContents)
|
| 432 |
assert result.contents[0].text == "Data for cursor/fastmcp"
|
| 433 |
|
|
|
|
| 434 |
async def test_resource_multiple_mismatched_params(self):
|
| 435 |
"""Test that mismatched parameters raise an error"""
|
| 436 |
mcp = FastMCP()
|
|
|
|
| 453 |
assert isinstance(result.contents[0], TextResourceContents)
|
| 454 |
assert result.contents[0].text == "Static data"
|
| 455 |
|
|
|
|
| 456 |
async def test_template_to_resource_conversion(self):
|
| 457 |
"""Test that templates are properly converted to resources when accessed"""
|
| 458 |
mcp = FastMCP()
|
|
|
|
| 475 |
class TestContextInjection:
|
| 476 |
"""Test context injection in tools."""
|
| 477 |
|
|
|
|
| 478 |
async def test_context_detection(self):
|
| 479 |
"""Test that context parameters are properly detected."""
|
| 480 |
mcp = FastMCP()
|
|
|
|
| 485 |
tool = mcp._tool_manager.add_tool(tool_with_context)
|
| 486 |
assert tool.context_kwarg == "ctx"
|
| 487 |
|
|
|
|
| 488 |
async def test_context_injection(self):
|
| 489 |
"""Test that context is properly injected into tool calls."""
|
| 490 |
mcp = FastMCP()
|
|
|
|
| 502 |
assert "Request" in content.text
|
| 503 |
assert "42" in content.text
|
| 504 |
|
|
|
|
| 505 |
async def test_async_context(self):
|
| 506 |
"""Test that context works in async functions."""
|
| 507 |
mcp = FastMCP()
|
|
|
|
| 519 |
assert "Async request" in content.text
|
| 520 |
assert "42" in content.text
|
| 521 |
|
|
|
|
| 522 |
async def test_context_logging(self):
|
| 523 |
from unittest.mock import patch
|
| 524 |
|
|
|
|
| 556 |
level="error", data="Error message", logger=None
|
| 557 |
)
|
| 558 |
|
|
|
|
| 559 |
async def test_optional_context(self):
|
| 560 |
"""Test that context is optional."""
|
| 561 |
mcp = FastMCP()
|
|
|
|
| 571 |
assert isinstance(content, TextContent)
|
| 572 |
assert content.text == "42"
|
| 573 |
|
|
|
|
| 574 |
async def test_context_resource_access(self):
|
| 575 |
"""Test that context can access resources."""
|
| 576 |
mcp = FastMCP()
|
|
|
|
| 598 |
class TestServerPrompts:
|
| 599 |
"""Test prompt functionality in FastMCP server."""
|
| 600 |
|
|
|
|
| 601 |
async def test_prompt_decorator(self):
|
| 602 |
"""Test that the prompt decorator registers prompts correctly."""
|
| 603 |
mcp = FastMCP()
|
|
|
|
| 614 |
assert isinstance(content[0].content, TextContent)
|
| 615 |
assert content[0].content.text == "Hello, world!"
|
| 616 |
|
|
|
|
| 617 |
async def test_prompt_decorator_with_name(self):
|
| 618 |
"""Test prompt decorator with custom name."""
|
| 619 |
mcp = FastMCP()
|
|
|
|
| 629 |
assert isinstance(content[0].content, TextContent)
|
| 630 |
assert content[0].content.text == "Hello, world!"
|
| 631 |
|
|
|
|
| 632 |
async def test_prompt_decorator_with_description(self):
|
| 633 |
"""Test prompt decorator with custom description."""
|
| 634 |
mcp = FastMCP()
|
|
|
|
| 653 |
def fn() -> str:
|
| 654 |
return "Hello, world!"
|
| 655 |
|
|
|
|
| 656 |
async def test_list_prompts(self):
|
| 657 |
"""Test listing prompts through MCP protocol."""
|
| 658 |
mcp = FastMCP()
|
|
|
|
| 674 |
assert prompt.arguments[1].name == "optional"
|
| 675 |
assert prompt.arguments[1].required is False
|
| 676 |
|
|
|
|
| 677 |
async def test_get_prompt(self):
|
| 678 |
"""Test getting a prompt through MCP protocol."""
|
| 679 |
mcp = FastMCP()
|
|
|
|
| 691 |
assert isinstance(content, TextContent)
|
| 692 |
assert content.text == "Hello, World!"
|
| 693 |
|
|
|
|
| 694 |
async def test_get_prompt_with_resource(self):
|
| 695 |
"""Test getting a prompt that returns resource content."""
|
| 696 |
mcp = FastMCP()
|
|
|
|
| 720 |
assert resource.text == "File contents"
|
| 721 |
assert resource.mimeType == "text/plain"
|
| 722 |
|
|
|
|
| 723 |
async def test_get_unknown_prompt(self):
|
| 724 |
"""Test error when getting unknown prompt."""
|
| 725 |
mcp = FastMCP()
|
|
|
|
| 727 |
with pytest.raises(McpError, match="Unknown prompt"):
|
| 728 |
await client.get_prompt("unknown")
|
| 729 |
|
|
|
|
| 730 |
async def test_get_prompt_missing_args(self):
|
| 731 |
"""Test error when required arguments are missing."""
|
| 732 |
mcp = FastMCP()
|