Rename FastMCP to MCPServer (#1951)
This commit is contained in:
parent
4a2d83a0cb
commit
65c614e48e
25 changed files with 713 additions and 693 deletions
|
|
@ -16,7 +16,7 @@ from starlette.applications import Starlette
|
|||
from mcp.server.auth.provider import AuthorizeError, RegistrationError, TokenError
|
||||
from mcp.server.auth.routes import create_auth_routes
|
||||
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
|
||||
from tests.server.fastmcp.auth.test_auth_integration import MockOAuthProvider
|
||||
from tests.server.mcpserver.auth.test_auth_integration import MockOAuthProvider
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
|
|||
0
tests/server/mcpserver/__init__.py
Normal file
0
tests/server/mcpserver/__init__.py
Normal file
1
tests/server/mcpserver/auth/__init__.py
Normal file
1
tests/server/mcpserver/auth/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Tests for the MCP server auth components."""
|
||||
1642
tests/server/mcpserver/auth/test_auth_integration.py
Normal file
1642
tests/server/mcpserver/auth/test_auth_integration.py
Normal file
File diff suppressed because it is too large
Load diff
0
tests/server/mcpserver/prompts/__init__.py
Normal file
0
tests/server/mcpserver/prompts/__init__.py
Normal file
183
tests/server/mcpserver/prompts/test_base.py
Normal file
183
tests/server/mcpserver/prompts/test_base.py
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, Prompt, TextContent, UserMessage
|
||||
from mcp.types import EmbeddedResource, TextResourceContents
|
||||
|
||||
|
||||
class TestRenderPrompt:
|
||||
@pytest.mark.anyio
|
||||
async def test_basic_fn(self):
|
||||
def fn() -> str:
|
||||
return "Hello, world!"
|
||||
|
||||
prompt = Prompt.from_function(fn)
|
||||
assert await prompt.render() == [UserMessage(content=TextContent(type="text", text="Hello, world!"))]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_async_fn(self):
|
||||
async def fn() -> str:
|
||||
return "Hello, world!"
|
||||
|
||||
prompt = Prompt.from_function(fn)
|
||||
assert await prompt.render() == [UserMessage(content=TextContent(type="text", text="Hello, world!"))]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_fn_with_args(self):
|
||||
async def fn(name: str, age: int = 30) -> str:
|
||||
return f"Hello, {name}! You're {age} years old."
|
||||
|
||||
prompt = Prompt.from_function(fn)
|
||||
assert await prompt.render(arguments={"name": "World"}) == [
|
||||
UserMessage(content=TextContent(type="text", text="Hello, World! You're 30 years old."))
|
||||
]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_fn_with_invalid_kwargs(self):
|
||||
async def fn(name: str, age: int = 30) -> str: # pragma: no cover
|
||||
return f"Hello, {name}! You're {age} years old."
|
||||
|
||||
prompt = Prompt.from_function(fn)
|
||||
with pytest.raises(ValueError):
|
||||
await prompt.render(arguments={"age": 40})
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_fn_returns_message(self):
|
||||
async def fn() -> UserMessage:
|
||||
return UserMessage(content="Hello, world!")
|
||||
|
||||
prompt = Prompt.from_function(fn)
|
||||
assert await prompt.render() == [UserMessage(content=TextContent(type="text", text="Hello, world!"))]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_fn_returns_assistant_message(self):
|
||||
async def fn() -> AssistantMessage:
|
||||
return AssistantMessage(content=TextContent(type="text", text="Hello, world!"))
|
||||
|
||||
prompt = Prompt.from_function(fn)
|
||||
assert await prompt.render() == [AssistantMessage(content=TextContent(type="text", text="Hello, world!"))]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_fn_returns_multiple_messages(self):
|
||||
expected: list[Message] = [
|
||||
UserMessage("Hello, world!"),
|
||||
AssistantMessage("How can I help you today?"),
|
||||
UserMessage("I'm looking for a restaurant in the center of town."),
|
||||
]
|
||||
|
||||
async def fn() -> list[Message]:
|
||||
return expected
|
||||
|
||||
prompt = Prompt.from_function(fn)
|
||||
assert await prompt.render() == expected
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_fn_returns_list_of_strings(self):
|
||||
expected = [
|
||||
"Hello, world!",
|
||||
"I'm looking for a restaurant in the center of town.",
|
||||
]
|
||||
|
||||
async def fn() -> list[str]:
|
||||
return expected
|
||||
|
||||
prompt = Prompt.from_function(fn)
|
||||
assert await prompt.render() == [UserMessage(t) for t in expected]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_fn_returns_resource_content(self):
|
||||
"""Test returning a message with resource content."""
|
||||
|
||||
async def fn() -> UserMessage:
|
||||
return UserMessage(
|
||||
content=EmbeddedResource(
|
||||
type="resource",
|
||||
resource=TextResourceContents(
|
||||
uri="file://file.txt",
|
||||
text="File contents",
|
||||
mime_type="text/plain",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
prompt = Prompt.from_function(fn)
|
||||
assert await prompt.render() == [
|
||||
UserMessage(
|
||||
content=EmbeddedResource(
|
||||
type="resource",
|
||||
resource=TextResourceContents(
|
||||
uri="file://file.txt",
|
||||
text="File contents",
|
||||
mime_type="text/plain",
|
||||
),
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_fn_returns_mixed_content(self):
|
||||
"""Test returning messages with mixed content types."""
|
||||
|
||||
async def fn() -> list[Message]:
|
||||
return [
|
||||
UserMessage(content="Please analyze this file:"),
|
||||
UserMessage(
|
||||
content=EmbeddedResource(
|
||||
type="resource",
|
||||
resource=TextResourceContents(
|
||||
uri="file://file.txt",
|
||||
text="File contents",
|
||||
mime_type="text/plain",
|
||||
),
|
||||
)
|
||||
),
|
||||
AssistantMessage(content="I'll help analyze that file."),
|
||||
]
|
||||
|
||||
prompt = Prompt.from_function(fn)
|
||||
assert await prompt.render() == [
|
||||
UserMessage(content=TextContent(type="text", text="Please analyze this file:")),
|
||||
UserMessage(
|
||||
content=EmbeddedResource(
|
||||
type="resource",
|
||||
resource=TextResourceContents(
|
||||
uri="file://file.txt",
|
||||
text="File contents",
|
||||
mime_type="text/plain",
|
||||
),
|
||||
)
|
||||
),
|
||||
AssistantMessage(content=TextContent(type="text", text="I'll help analyze that file.")),
|
||||
]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_fn_returns_dict_with_resource(self):
|
||||
"""Test returning a dict with resource content."""
|
||||
|
||||
async def fn() -> dict[str, Any]:
|
||||
return {
|
||||
"role": "user",
|
||||
"content": {
|
||||
"type": "resource",
|
||||
"resource": {
|
||||
"uri": "file://file.txt",
|
||||
"text": "File contents",
|
||||
"mimeType": "text/plain",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
prompt = Prompt.from_function(fn)
|
||||
assert await prompt.render() == [
|
||||
UserMessage(
|
||||
content=EmbeddedResource(
|
||||
type="resource",
|
||||
resource=TextResourceContents(
|
||||
uri="file://file.txt",
|
||||
text="File contents",
|
||||
mime_type="text/plain",
|
||||
),
|
||||
)
|
||||
)
|
||||
]
|
||||
108
tests/server/mcpserver/prompts/test_manager.py
Normal file
108
tests/server/mcpserver/prompts/test_manager.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import pytest
|
||||
|
||||
from mcp.server.mcpserver.prompts.base import Prompt, TextContent, UserMessage
|
||||
from mcp.server.mcpserver.prompts.manager import PromptManager
|
||||
|
||||
|
||||
class TestPromptManager:
|
||||
def test_add_prompt(self):
|
||||
"""Test adding a prompt to the manager."""
|
||||
|
||||
def fn() -> str: # pragma: no cover
|
||||
return "Hello, world!"
|
||||
|
||||
manager = PromptManager()
|
||||
prompt = Prompt.from_function(fn)
|
||||
added = manager.add_prompt(prompt)
|
||||
assert added == prompt
|
||||
assert manager.get_prompt("fn") == prompt
|
||||
|
||||
def test_add_duplicate_prompt(self, caplog: pytest.LogCaptureFixture):
|
||||
"""Test adding the same prompt twice."""
|
||||
|
||||
def fn() -> str: # pragma: no cover
|
||||
return "Hello, world!"
|
||||
|
||||
manager = PromptManager()
|
||||
prompt = Prompt.from_function(fn)
|
||||
first = manager.add_prompt(prompt)
|
||||
second = manager.add_prompt(prompt)
|
||||
assert first == second
|
||||
assert "Prompt already exists" in caplog.text
|
||||
|
||||
def test_disable_warn_on_duplicate_prompts(self, caplog: pytest.LogCaptureFixture):
|
||||
"""Test disabling warning on duplicate prompts."""
|
||||
|
||||
def fn() -> str: # pragma: no cover
|
||||
return "Hello, world!"
|
||||
|
||||
manager = PromptManager(warn_on_duplicate_prompts=False)
|
||||
prompt = Prompt.from_function(fn)
|
||||
first = manager.add_prompt(prompt)
|
||||
second = manager.add_prompt(prompt)
|
||||
assert first == second
|
||||
assert "Prompt already exists" not in caplog.text
|
||||
|
||||
def test_list_prompts(self):
|
||||
"""Test listing all prompts."""
|
||||
|
||||
def fn1() -> str: # pragma: no cover
|
||||
return "Hello, world!"
|
||||
|
||||
def fn2() -> str: # pragma: no cover
|
||||
return "Goodbye, world!"
|
||||
|
||||
manager = PromptManager()
|
||||
prompt1 = Prompt.from_function(fn1)
|
||||
prompt2 = Prompt.from_function(fn2)
|
||||
manager.add_prompt(prompt1)
|
||||
manager.add_prompt(prompt2)
|
||||
prompts = manager.list_prompts()
|
||||
assert len(prompts) == 2
|
||||
assert prompts == [prompt1, prompt2]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_render_prompt(self):
|
||||
"""Test rendering a prompt."""
|
||||
|
||||
def fn() -> str:
|
||||
return "Hello, world!"
|
||||
|
||||
manager = PromptManager()
|
||||
prompt = Prompt.from_function(fn)
|
||||
manager.add_prompt(prompt)
|
||||
messages = await manager.render_prompt("fn")
|
||||
assert messages == [UserMessage(content=TextContent(type="text", text="Hello, world!"))]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_render_prompt_with_args(self):
|
||||
"""Test rendering a prompt with arguments."""
|
||||
|
||||
def fn(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
manager = PromptManager()
|
||||
prompt = Prompt.from_function(fn)
|
||||
manager.add_prompt(prompt)
|
||||
messages = await manager.render_prompt("fn", arguments={"name": "World"})
|
||||
assert messages == [UserMessage(content=TextContent(type="text", text="Hello, World!"))]
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_render_unknown_prompt(self):
|
||||
"""Test rendering a non-existent prompt."""
|
||||
manager = PromptManager()
|
||||
with pytest.raises(ValueError, match="Unknown prompt: unknown"):
|
||||
await manager.render_prompt("unknown")
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_render_prompt_with_missing_args(self):
|
||||
"""Test rendering a prompt with missing required arguments."""
|
||||
|
||||
def fn(name: str) -> str: # pragma: no cover
|
||||
return f"Hello, {name}!"
|
||||
|
||||
manager = PromptManager()
|
||||
prompt = Prompt.from_function(fn)
|
||||
manager.add_prompt(prompt)
|
||||
with pytest.raises(ValueError, match="Missing required arguments"):
|
||||
await manager.render_prompt("fn")
|
||||
0
tests/server/mcpserver/resources/__init__.py
Normal file
0
tests/server/mcpserver/resources/__init__.py
Normal file
116
tests/server/mcpserver/resources/test_file_resources.py
Normal file
116
tests/server/mcpserver/resources/test_file_resources.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import os
|
||||
from pathlib import Path
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp.server.mcpserver.resources import FileResource
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_file():
|
||||
"""Create a temporary file for testing.
|
||||
|
||||
File is automatically cleaned up after the test if it still exists.
|
||||
"""
|
||||
content = "test content"
|
||||
with NamedTemporaryFile(mode="w", delete=False) as f:
|
||||
f.write(content)
|
||||
path = Path(f.name).resolve()
|
||||
yield path
|
||||
try: # pragma: lax no cover
|
||||
path.unlink()
|
||||
except FileNotFoundError: # pragma: lax no cover
|
||||
pass # File was already deleted by the test
|
||||
|
||||
|
||||
class TestFileResource:
|
||||
"""Test FileResource functionality."""
|
||||
|
||||
def test_file_resource_creation(self, temp_file: Path):
|
||||
"""Test creating a FileResource."""
|
||||
resource = FileResource(
|
||||
uri=temp_file.as_uri(),
|
||||
name="test",
|
||||
description="test file",
|
||||
path=temp_file,
|
||||
)
|
||||
assert str(resource.uri) == temp_file.as_uri()
|
||||
assert resource.name == "test"
|
||||
assert resource.description == "test file"
|
||||
assert resource.mime_type == "text/plain" # default
|
||||
assert resource.path == temp_file
|
||||
assert resource.is_binary is False # default
|
||||
|
||||
def test_file_resource_str_path_conversion(self, temp_file: Path):
|
||||
"""Test FileResource handles string paths."""
|
||||
resource = FileResource(
|
||||
uri=f"file://{temp_file}",
|
||||
name="test",
|
||||
path=Path(str(temp_file)),
|
||||
)
|
||||
assert isinstance(resource.path, Path)
|
||||
assert resource.path.is_absolute()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_read_text_file(self, temp_file: Path):
|
||||
"""Test reading a text file."""
|
||||
resource = FileResource(
|
||||
uri=f"file://{temp_file}",
|
||||
name="test",
|
||||
path=temp_file,
|
||||
)
|
||||
content = await resource.read()
|
||||
assert content == "test content"
|
||||
assert resource.mime_type == "text/plain"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_read_binary_file(self, temp_file: Path):
|
||||
"""Test reading a file as binary."""
|
||||
resource = FileResource(
|
||||
uri=f"file://{temp_file}",
|
||||
name="test",
|
||||
path=temp_file,
|
||||
is_binary=True,
|
||||
)
|
||||
content = await resource.read()
|
||||
assert isinstance(content, bytes)
|
||||
assert content == b"test content"
|
||||
|
||||
def test_relative_path_error(self):
|
||||
"""Test error on relative path."""
|
||||
with pytest.raises(ValueError, match="Path must be absolute"):
|
||||
FileResource(
|
||||
uri="file:///test.txt",
|
||||
name="test",
|
||||
path=Path("test.txt"),
|
||||
)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_missing_file_error(self, temp_file: Path):
|
||||
"""Test error when file doesn't exist."""
|
||||
# Create path to non-existent file
|
||||
missing = temp_file.parent / "missing.txt"
|
||||
resource = FileResource(
|
||||
uri="file:///missing.txt",
|
||||
name="test",
|
||||
path=missing,
|
||||
)
|
||||
with pytest.raises(ValueError, match="Error reading file"):
|
||||
await resource.read()
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="File permissions behave differently on Windows")
|
||||
@pytest.mark.anyio
|
||||
async def test_permission_error(self, temp_file: Path): # pragma: lax no cover
|
||||
"""Test reading a file without permissions."""
|
||||
temp_file.chmod(0o000) # Remove all permissions
|
||||
try:
|
||||
resource = FileResource(
|
||||
uri=temp_file.as_uri(),
|
||||
name="test",
|
||||
path=temp_file,
|
||||
)
|
||||
with pytest.raises(ValueError, match="Error reading file"):
|
||||
await resource.read()
|
||||
finally:
|
||||
temp_file.chmod(0o644) # Restore permissions
|
||||
192
tests/server/mcpserver/resources/test_function_resources.py
Normal file
192
tests/server/mcpserver/resources/test_function_resources.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mcp.server.mcpserver.resources import FunctionResource
|
||||
|
||||
|
||||
class TestFunctionResource:
|
||||
"""Test FunctionResource functionality."""
|
||||
|
||||
def test_function_resource_creation(self):
|
||||
"""Test creating a FunctionResource."""
|
||||
|
||||
def my_func() -> str: # pragma: no cover
|
||||
return "test content"
|
||||
|
||||
resource = FunctionResource(
|
||||
uri="fn://test",
|
||||
name="test",
|
||||
description="test function",
|
||||
fn=my_func,
|
||||
)
|
||||
assert str(resource.uri) == "fn://test"
|
||||
assert resource.name == "test"
|
||||
assert resource.description == "test function"
|
||||
assert resource.mime_type == "text/plain" # default
|
||||
assert resource.fn == my_func
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_read_text(self):
|
||||
"""Test reading text from a FunctionResource."""
|
||||
|
||||
def get_data() -> str:
|
||||
return "Hello, world!"
|
||||
|
||||
resource = FunctionResource(
|
||||
uri="function://test",
|
||||
name="test",
|
||||
fn=get_data,
|
||||
)
|
||||
content = await resource.read()
|
||||
assert content == "Hello, world!"
|
||||
assert resource.mime_type == "text/plain"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_read_binary(self):
|
||||
"""Test reading binary data from a FunctionResource."""
|
||||
|
||||
def get_data() -> bytes:
|
||||
return b"Hello, world!"
|
||||
|
||||
resource = FunctionResource(
|
||||
uri="function://test",
|
||||
name="test",
|
||||
fn=get_data,
|
||||
)
|
||||
content = await resource.read()
|
||||
assert content == b"Hello, world!"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_json_conversion(self):
|
||||
"""Test automatic JSON conversion of non-string results."""
|
||||
|
||||
def get_data() -> dict[str, str]:
|
||||
return {"key": "value"}
|
||||
|
||||
resource = FunctionResource(
|
||||
uri="function://test",
|
||||
name="test",
|
||||
fn=get_data,
|
||||
)
|
||||
content = await resource.read()
|
||||
assert isinstance(content, str)
|
||||
assert '"key": "value"' in content
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_error_handling(self):
|
||||
"""Test error handling in FunctionResource."""
|
||||
|
||||
def failing_func() -> str:
|
||||
raise ValueError("Test error")
|
||||
|
||||
resource = FunctionResource(
|
||||
uri="function://test",
|
||||
name="test",
|
||||
fn=failing_func,
|
||||
)
|
||||
with pytest.raises(ValueError, match="Error reading resource function://test"):
|
||||
await resource.read()
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_basemodel_conversion(self):
|
||||
"""Test handling of BaseModel types."""
|
||||
|
||||
class MyModel(BaseModel):
|
||||
name: str
|
||||
|
||||
resource = FunctionResource(
|
||||
uri="function://test",
|
||||
name="test",
|
||||
fn=lambda: MyModel(name="test"),
|
||||
)
|
||||
content = await resource.read()
|
||||
assert content == '{\n "name": "test"\n}'
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_custom_type_conversion(self):
|
||||
"""Test handling of custom types."""
|
||||
|
||||
class CustomData:
|
||||
def __str__(self) -> str:
|
||||
return "custom data"
|
||||
|
||||
def get_data() -> CustomData:
|
||||
return CustomData()
|
||||
|
||||
resource = FunctionResource(
|
||||
uri="function://test",
|
||||
name="test",
|
||||
fn=get_data,
|
||||
)
|
||||
content = await resource.read()
|
||||
assert isinstance(content, str)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_async_read_text(self):
|
||||
"""Test reading text from async FunctionResource."""
|
||||
|
||||
async def get_data() -> str:
|
||||
return "Hello, world!"
|
||||
|
||||
resource = FunctionResource(
|
||||
uri="function://test",
|
||||
name="test",
|
||||
fn=get_data,
|
||||
)
|
||||
content = await resource.read()
|
||||
assert content == "Hello, world!"
|
||||
assert resource.mime_type == "text/plain"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_from_function(self):
|
||||
"""Test creating a FunctionResource from a function."""
|
||||
|
||||
async def get_data() -> str: # pragma: no cover
|
||||
"""get_data returns a string"""
|
||||
return "Hello, world!"
|
||||
|
||||
resource = FunctionResource.from_function(
|
||||
fn=get_data,
|
||||
uri="function://test",
|
||||
name="test",
|
||||
)
|
||||
|
||||
assert resource.description == "get_data returns a string"
|
||||
assert resource.mime_type == "text/plain"
|
||||
assert resource.name == "test"
|
||||
assert resource.uri == "function://test"
|
||||
|
||||
|
||||
class TestFunctionResourceMetadata:
|
||||
def test_from_function_with_metadata(self):
|
||||
# from_function() accepts meta dict and stores it on the resource for static resources
|
||||
|
||||
def get_data() -> str: # pragma: no cover
|
||||
return "test data"
|
||||
|
||||
metadata = {"cache_ttl": 300, "tags": ["data", "readonly"]}
|
||||
|
||||
resource = FunctionResource.from_function(
|
||||
fn=get_data,
|
||||
uri="resource://data",
|
||||
meta=metadata,
|
||||
)
|
||||
|
||||
assert resource.meta is not None
|
||||
assert resource.meta == metadata
|
||||
assert resource.meta["cache_ttl"] == 300
|
||||
assert "data" in resource.meta["tags"]
|
||||
assert "readonly" in resource.meta["tags"]
|
||||
|
||||
def test_from_function_without_metadata(self):
|
||||
# meta parameter is optional and defaults to None for backward compatibility
|
||||
|
||||
def get_data() -> str: # pragma: no cover
|
||||
return "test data"
|
||||
|
||||
resource = FunctionResource.from_function(
|
||||
fn=get_data,
|
||||
uri="resource://data",
|
||||
)
|
||||
|
||||
assert resource.meta is None
|
||||
176
tests/server/mcpserver/resources/test_resource_manager.py
Normal file
176
tests/server/mcpserver/resources/test_resource_manager.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
from pathlib import Path
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
import pytest
|
||||
from pydantic import AnyUrl
|
||||
|
||||
from mcp.server.mcpserver.resources import FileResource, FunctionResource, ResourceManager, ResourceTemplate
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_file():
|
||||
"""Create a temporary file for testing.
|
||||
|
||||
File is automatically cleaned up after the test if it still exists.
|
||||
"""
|
||||
content = "test content"
|
||||
with NamedTemporaryFile(mode="w", delete=False) as f:
|
||||
f.write(content)
|
||||
path = Path(f.name).resolve()
|
||||
yield path
|
||||
try: # pragma: lax no cover
|
||||
path.unlink()
|
||||
except FileNotFoundError: # pragma: lax no cover
|
||||
pass # File was already deleted by the test
|
||||
|
||||
|
||||
class TestResourceManager:
|
||||
"""Test ResourceManager functionality."""
|
||||
|
||||
def test_add_resource(self, temp_file: Path):
|
||||
"""Test adding a resource."""
|
||||
manager = ResourceManager()
|
||||
resource = FileResource(
|
||||
uri=f"file://{temp_file}",
|
||||
name="test",
|
||||
path=temp_file,
|
||||
)
|
||||
added = manager.add_resource(resource)
|
||||
assert added == resource
|
||||
assert manager.list_resources() == [resource]
|
||||
|
||||
def test_add_duplicate_resource(self, temp_file: Path):
|
||||
"""Test adding the same resource twice."""
|
||||
manager = ResourceManager()
|
||||
resource = FileResource(
|
||||
uri=f"file://{temp_file}",
|
||||
name="test",
|
||||
path=temp_file,
|
||||
)
|
||||
first = manager.add_resource(resource)
|
||||
second = manager.add_resource(resource)
|
||||
assert first == second
|
||||
assert manager.list_resources() == [resource]
|
||||
|
||||
def test_warn_on_duplicate_resources(self, temp_file: Path, caplog: pytest.LogCaptureFixture):
|
||||
"""Test warning on duplicate resources."""
|
||||
manager = ResourceManager()
|
||||
resource = FileResource(
|
||||
uri=f"file://{temp_file}",
|
||||
name="test",
|
||||
path=temp_file,
|
||||
)
|
||||
manager.add_resource(resource)
|
||||
manager.add_resource(resource)
|
||||
assert "Resource already exists" in caplog.text
|
||||
|
||||
def test_disable_warn_on_duplicate_resources(self, temp_file: Path, caplog: pytest.LogCaptureFixture):
|
||||
"""Test disabling warning on duplicate resources."""
|
||||
manager = ResourceManager(warn_on_duplicate_resources=False)
|
||||
resource = FileResource(
|
||||
uri=f"file://{temp_file}",
|
||||
name="test",
|
||||
path=temp_file,
|
||||
)
|
||||
manager.add_resource(resource)
|
||||
manager.add_resource(resource)
|
||||
assert "Resource already exists" not in caplog.text
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_resource(self, temp_file: Path):
|
||||
"""Test getting a resource by URI."""
|
||||
manager = ResourceManager()
|
||||
resource = FileResource(
|
||||
uri=f"file://{temp_file}",
|
||||
name="test",
|
||||
path=temp_file,
|
||||
)
|
||||
manager.add_resource(resource)
|
||||
retrieved = await manager.get_resource(resource.uri)
|
||||
assert retrieved == resource
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_resource_from_template(self):
|
||||
"""Test getting a resource through a template."""
|
||||
manager = ResourceManager()
|
||||
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
template = ResourceTemplate.from_function(
|
||||
fn=greet,
|
||||
uri_template="greet://{name}",
|
||||
name="greeter",
|
||||
)
|
||||
manager._templates[template.uri_template] = template
|
||||
|
||||
resource = await manager.get_resource(AnyUrl("greet://world"))
|
||||
assert isinstance(resource, FunctionResource)
|
||||
content = await resource.read()
|
||||
assert content == "Hello, world!"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_unknown_resource(self):
|
||||
"""Test getting a non-existent resource."""
|
||||
manager = ResourceManager()
|
||||
with pytest.raises(ValueError, match="Unknown resource"):
|
||||
await manager.get_resource(AnyUrl("unknown://test"))
|
||||
|
||||
def test_list_resources(self, temp_file: Path):
|
||||
"""Test listing all resources."""
|
||||
manager = ResourceManager()
|
||||
resource1 = FileResource(
|
||||
uri=f"file://{temp_file}",
|
||||
name="test1",
|
||||
path=temp_file,
|
||||
)
|
||||
resource2 = FileResource(
|
||||
uri=f"file://{temp_file}2",
|
||||
name="test2",
|
||||
path=temp_file,
|
||||
)
|
||||
manager.add_resource(resource1)
|
||||
manager.add_resource(resource2)
|
||||
resources = manager.list_resources()
|
||||
assert len(resources) == 2
|
||||
assert resources == [resource1, resource2]
|
||||
|
||||
|
||||
class TestResourceManagerMetadata:
|
||||
"""Test ResourceManager Metadata"""
|
||||
|
||||
def test_add_template_with_metadata(self):
|
||||
"""Test that ResourceManager.add_template() accepts and passes meta parameter."""
|
||||
|
||||
manager = ResourceManager()
|
||||
|
||||
def get_item(id: str) -> str: # pragma: no cover
|
||||
return f"Item {id}"
|
||||
|
||||
metadata = {"source": "database", "cached": True}
|
||||
|
||||
template = manager.add_template(
|
||||
fn=get_item,
|
||||
uri_template="resource://items/{id}",
|
||||
meta=metadata,
|
||||
)
|
||||
|
||||
assert template.meta is not None
|
||||
assert template.meta == metadata
|
||||
assert template.meta["source"] == "database"
|
||||
assert template.meta["cached"] is True
|
||||
|
||||
def test_add_template_without_metadata(self):
|
||||
"""Test that ResourceManager.add_template() works without meta parameter."""
|
||||
|
||||
manager = ResourceManager()
|
||||
|
||||
def get_item(id: str) -> str: # pragma: no cover
|
||||
return f"Item {id}"
|
||||
|
||||
template = manager.add_template(
|
||||
fn=get_item,
|
||||
uri_template="resource://items/{id}",
|
||||
)
|
||||
|
||||
assert template.meta is None
|
||||
307
tests/server/mcpserver/resources/test_resource_template.py
Normal file
307
tests/server/mcpserver/resources/test_resource_template.py
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
import json
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from mcp.server.mcpserver.resources import FunctionResource, ResourceTemplate
|
||||
from mcp.types import Annotations
|
||||
|
||||
|
||||
class TestResourceTemplate:
|
||||
"""Test ResourceTemplate functionality."""
|
||||
|
||||
def test_template_creation(self):
|
||||
"""Test creating a template from a function."""
|
||||
|
||||
def my_func(key: str, value: int) -> dict[str, Any]:
|
||||
return {"key": key, "value": value}
|
||||
|
||||
template = ResourceTemplate.from_function(
|
||||
fn=my_func,
|
||||
uri_template="test://{key}/{value}",
|
||||
name="test",
|
||||
)
|
||||
assert template.uri_template == "test://{key}/{value}"
|
||||
assert template.name == "test"
|
||||
assert template.mime_type == "text/plain" # default
|
||||
assert template.fn(key="test", value=42) == my_func(key="test", value=42)
|
||||
|
||||
def test_template_matches(self):
|
||||
"""Test matching URIs against a template."""
|
||||
|
||||
def my_func(key: str, value: int) -> dict[str, Any]: # pragma: no cover
|
||||
return {"key": key, "value": value}
|
||||
|
||||
template = ResourceTemplate.from_function(
|
||||
fn=my_func,
|
||||
uri_template="test://{key}/{value}",
|
||||
name="test",
|
||||
)
|
||||
|
||||
# Valid match
|
||||
params = template.matches("test://foo/123")
|
||||
assert params == {"key": "foo", "value": "123"}
|
||||
|
||||
# No match
|
||||
assert template.matches("test://foo") is None
|
||||
assert template.matches("other://foo/123") is None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_resource(self):
|
||||
"""Test creating a resource from a template."""
|
||||
|
||||
def my_func(key: str, value: int) -> dict[str, Any]:
|
||||
return {"key": key, "value": value}
|
||||
|
||||
template = ResourceTemplate.from_function(
|
||||
fn=my_func,
|
||||
uri_template="test://{key}/{value}",
|
||||
name="test",
|
||||
)
|
||||
|
||||
resource = await template.create_resource(
|
||||
"test://foo/123",
|
||||
{"key": "foo", "value": 123},
|
||||
)
|
||||
|
||||
assert isinstance(resource, FunctionResource)
|
||||
content = await resource.read()
|
||||
assert isinstance(content, str)
|
||||
data = json.loads(content)
|
||||
assert data == {"key": "foo", "value": 123}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_template_error(self):
|
||||
"""Test error handling in template resource creation."""
|
||||
|
||||
def failing_func(x: str) -> str:
|
||||
raise ValueError("Test error")
|
||||
|
||||
template = ResourceTemplate.from_function(
|
||||
fn=failing_func,
|
||||
uri_template="fail://{x}",
|
||||
name="fail",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Error creating resource from template"):
|
||||
await template.create_resource("fail://test", {"x": "test"})
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_async_text_resource(self):
|
||||
"""Test creating a text resource from async function."""
|
||||
|
||||
async def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
template = ResourceTemplate.from_function(
|
||||
fn=greet,
|
||||
uri_template="greet://{name}",
|
||||
name="greeter",
|
||||
)
|
||||
|
||||
resource = await template.create_resource(
|
||||
"greet://world",
|
||||
{"name": "world"},
|
||||
)
|
||||
|
||||
assert isinstance(resource, FunctionResource)
|
||||
content = await resource.read()
|
||||
assert content == "Hello, world!"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_async_binary_resource(self):
|
||||
"""Test creating a binary resource from async function."""
|
||||
|
||||
async def get_bytes(value: str) -> bytes:
|
||||
return value.encode()
|
||||
|
||||
template = ResourceTemplate.from_function(
|
||||
fn=get_bytes,
|
||||
uri_template="bytes://{value}",
|
||||
name="bytes",
|
||||
)
|
||||
|
||||
resource = await template.create_resource(
|
||||
"bytes://test",
|
||||
{"value": "test"},
|
||||
)
|
||||
|
||||
assert isinstance(resource, FunctionResource)
|
||||
content = await resource.read()
|
||||
assert content == b"test"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_basemodel_conversion(self):
|
||||
"""Test handling of BaseModel types."""
|
||||
|
||||
class MyModel(BaseModel):
|
||||
key: str
|
||||
value: int
|
||||
|
||||
def get_data(key: str, value: int) -> MyModel:
|
||||
return MyModel(key=key, value=value)
|
||||
|
||||
template = ResourceTemplate.from_function(
|
||||
fn=get_data,
|
||||
uri_template="test://{key}/{value}",
|
||||
name="test",
|
||||
)
|
||||
|
||||
resource = await template.create_resource(
|
||||
"test://foo/123",
|
||||
{"key": "foo", "value": 123},
|
||||
)
|
||||
|
||||
assert isinstance(resource, FunctionResource)
|
||||
content = await resource.read()
|
||||
assert isinstance(content, str)
|
||||
data = json.loads(content)
|
||||
assert data == {"key": "foo", "value": 123}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_custom_type_conversion(self):
|
||||
"""Test handling of custom types."""
|
||||
|
||||
class CustomData:
|
||||
def __init__(self, value: str):
|
||||
self.value = value
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
def get_data(value: str) -> CustomData:
|
||||
return CustomData(value)
|
||||
|
||||
template = ResourceTemplate.from_function(
|
||||
fn=get_data,
|
||||
uri_template="test://{value}",
|
||||
name="test",
|
||||
)
|
||||
|
||||
resource = await template.create_resource(
|
||||
"test://hello",
|
||||
{"value": "hello"},
|
||||
)
|
||||
|
||||
assert isinstance(resource, FunctionResource)
|
||||
content = await resource.read()
|
||||
assert content == '"hello"'
|
||||
|
||||
|
||||
class TestResourceTemplateAnnotations:
|
||||
"""Test annotations on resource templates."""
|
||||
|
||||
def test_template_with_annotations(self):
|
||||
"""Test creating a template with annotations."""
|
||||
|
||||
def get_user_data(user_id: str) -> str: # pragma: no cover
|
||||
return f"User {user_id}"
|
||||
|
||||
annotations = Annotations(priority=0.9)
|
||||
|
||||
template = ResourceTemplate.from_function(
|
||||
fn=get_user_data, uri_template="resource://users/{user_id}", annotations=annotations
|
||||
)
|
||||
|
||||
assert template.annotations is not None
|
||||
assert template.annotations.priority == 0.9
|
||||
|
||||
def test_template_without_annotations(self):
|
||||
"""Test that annotations are optional for templates."""
|
||||
|
||||
def get_user_data(user_id: str) -> str: # pragma: no cover
|
||||
return f"User {user_id}"
|
||||
|
||||
template = ResourceTemplate.from_function(fn=get_user_data, uri_template="resource://users/{user_id}")
|
||||
|
||||
assert template.annotations is None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_template_annotations_in_mcpserver(self):
|
||||
"""Test template annotations via an MCPServer decorator."""
|
||||
|
||||
mcp = MCPServer()
|
||||
|
||||
@mcp.resource("resource://dynamic/{id}", annotations=Annotations(audience=["user"], priority=0.7))
|
||||
def get_dynamic(id: str) -> str: # pragma: no cover
|
||||
"""A dynamic annotated resource."""
|
||||
return f"Data for {id}"
|
||||
|
||||
templates = await mcp.list_resource_templates()
|
||||
assert len(templates) == 1
|
||||
assert templates[0].annotations is not None
|
||||
assert templates[0].annotations.audience == ["user"]
|
||||
assert templates[0].annotations.priority == 0.7
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_template_created_resources_inherit_annotations(self):
|
||||
"""Test that resources created from templates inherit annotations."""
|
||||
|
||||
def get_item(item_id: str) -> str:
|
||||
return f"Item {item_id}"
|
||||
|
||||
annotations = Annotations(priority=0.6)
|
||||
|
||||
template = ResourceTemplate.from_function(
|
||||
fn=get_item, uri_template="resource://items/{item_id}", annotations=annotations
|
||||
)
|
||||
|
||||
# Create a resource from the template
|
||||
resource = await template.create_resource("resource://items/123", {"item_id": "123"})
|
||||
|
||||
# The resource should inherit the template's annotations
|
||||
assert resource.annotations is not None
|
||||
assert resource.annotations.priority == 0.6
|
||||
|
||||
# Verify the resource works correctly
|
||||
content = await resource.read()
|
||||
assert content == "Item 123"
|
||||
|
||||
|
||||
class TestResourceTemplateMetadata:
|
||||
"""Test ResourceTemplate meta handling."""
|
||||
|
||||
def test_template_from_function_with_metadata(self):
|
||||
"""Test that ResourceTemplate.from_function() accepts and stores meta parameter."""
|
||||
|
||||
def get_user(user_id: str) -> str: # pragma: no cover
|
||||
return f"User {user_id}"
|
||||
|
||||
metadata = {"requires_auth": True, "rate_limit": 100}
|
||||
|
||||
template = ResourceTemplate.from_function(
|
||||
fn=get_user,
|
||||
uri_template="resource://users/{user_id}",
|
||||
meta=metadata,
|
||||
)
|
||||
|
||||
assert template.meta is not None
|
||||
assert template.meta == metadata
|
||||
assert template.meta["requires_auth"] is True
|
||||
assert template.meta["rate_limit"] == 100
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_template_created_resources_inherit_metadata(self):
|
||||
"""Test that resources created from templates inherit meta from template."""
|
||||
|
||||
def get_item(item_id: str) -> str:
|
||||
return f"Item {item_id}"
|
||||
|
||||
metadata = {"category": "inventory", "cacheable": True}
|
||||
|
||||
template = ResourceTemplate.from_function(
|
||||
fn=get_item,
|
||||
uri_template="resource://items/{item_id}",
|
||||
meta=metadata,
|
||||
)
|
||||
|
||||
# Create a resource from the template
|
||||
resource = await template.create_resource("resource://items/123", {"item_id": "123"})
|
||||
|
||||
# The resource should inherit the template's metadata
|
||||
assert resource.meta is not None
|
||||
assert resource.meta == metadata
|
||||
assert resource.meta["category"] == "inventory"
|
||||
assert resource.meta["cacheable"] is True
|
||||
232
tests/server/mcpserver/resources/test_resources.py
Normal file
232
tests/server/mcpserver/resources/test_resources.py
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import pytest
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from mcp.server.mcpserver.resources import FunctionResource, Resource
|
||||
from mcp.types import Annotations
|
||||
|
||||
|
||||
class TestResourceValidation:
|
||||
"""Test base Resource validation."""
|
||||
|
||||
def test_resource_uri_accepts_any_string(self):
|
||||
"""Test that URI field accepts any string per MCP spec."""
|
||||
|
||||
def dummy_func() -> str: # pragma: no cover
|
||||
return "data"
|
||||
|
||||
# Valid URI
|
||||
resource = FunctionResource(
|
||||
uri="http://example.com/data",
|
||||
name="test",
|
||||
fn=dummy_func,
|
||||
)
|
||||
assert resource.uri == "http://example.com/data"
|
||||
|
||||
# Relative path - now accepted per MCP spec
|
||||
resource = FunctionResource(
|
||||
uri="users/me",
|
||||
name="test",
|
||||
fn=dummy_func,
|
||||
)
|
||||
assert resource.uri == "users/me"
|
||||
|
||||
# Custom scheme
|
||||
resource = FunctionResource(
|
||||
uri="custom://resource",
|
||||
name="test",
|
||||
fn=dummy_func,
|
||||
)
|
||||
assert resource.uri == "custom://resource"
|
||||
|
||||
def test_resource_name_from_uri(self):
|
||||
"""Test name is extracted from URI if not provided."""
|
||||
|
||||
def dummy_func() -> str: # pragma: no cover
|
||||
return "data"
|
||||
|
||||
resource = FunctionResource(
|
||||
uri="resource://my-resource",
|
||||
fn=dummy_func,
|
||||
)
|
||||
assert resource.name == "resource://my-resource"
|
||||
|
||||
def test_resource_name_validation(self):
|
||||
"""Test name validation."""
|
||||
|
||||
def dummy_func() -> str: # pragma: no cover
|
||||
return "data"
|
||||
|
||||
# Must provide either name or URI
|
||||
with pytest.raises(ValueError, match="Either name or uri must be provided"):
|
||||
FunctionResource(
|
||||
fn=dummy_func,
|
||||
)
|
||||
|
||||
# Explicit name takes precedence over URI
|
||||
resource = FunctionResource(
|
||||
uri="resource://uri-name",
|
||||
name="explicit-name",
|
||||
fn=dummy_func,
|
||||
)
|
||||
assert resource.name == "explicit-name"
|
||||
|
||||
def test_resource_mime_type(self):
|
||||
"""Test mime type handling."""
|
||||
|
||||
def dummy_func() -> str: # pragma: no cover
|
||||
return "data"
|
||||
|
||||
# Default mime type
|
||||
resource = FunctionResource(
|
||||
uri="resource://test",
|
||||
fn=dummy_func,
|
||||
)
|
||||
assert resource.mime_type == "text/plain"
|
||||
|
||||
# Custom mime type
|
||||
resource = FunctionResource(
|
||||
uri="resource://test",
|
||||
fn=dummy_func,
|
||||
mime_type="application/json",
|
||||
)
|
||||
assert resource.mime_type == "application/json"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_resource_read_abstract(self):
|
||||
"""Test that Resource.read() is abstract."""
|
||||
|
||||
class ConcreteResource(Resource):
|
||||
pass
|
||||
|
||||
with pytest.raises(TypeError, match="abstract method"):
|
||||
ConcreteResource(uri="test://test", name="test") # type: ignore
|
||||
|
||||
|
||||
class TestResourceAnnotations:
|
||||
"""Test annotations on resources."""
|
||||
|
||||
def test_resource_with_annotations(self):
|
||||
"""Test creating a resource with annotations."""
|
||||
|
||||
def get_data() -> str: # pragma: no cover
|
||||
return "data"
|
||||
|
||||
annotations = Annotations(audience=["user"], priority=0.8)
|
||||
|
||||
resource = FunctionResource.from_function(fn=get_data, uri="resource://test", annotations=annotations)
|
||||
|
||||
assert resource.annotations is not None
|
||||
assert resource.annotations.audience == ["user"]
|
||||
assert resource.annotations.priority == 0.8
|
||||
|
||||
def test_resource_without_annotations(self):
|
||||
"""Test that annotations are optional."""
|
||||
|
||||
def get_data() -> str: # pragma: no cover
|
||||
return "data"
|
||||
|
||||
resource = FunctionResource.from_function(fn=get_data, uri="resource://test")
|
||||
|
||||
assert resource.annotations is None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_resource_annotations_in_mcpserver(self):
|
||||
"""Test resource annotations via MCPServer decorator."""
|
||||
|
||||
mcp = MCPServer()
|
||||
|
||||
@mcp.resource("resource://annotated", annotations=Annotations(audience=["assistant"], priority=0.5))
|
||||
def get_annotated() -> str: # pragma: no cover
|
||||
"""An annotated resource."""
|
||||
return "annotated data"
|
||||
|
||||
resources = await mcp.list_resources()
|
||||
assert len(resources) == 1
|
||||
assert resources[0].annotations is not None
|
||||
assert resources[0].annotations.audience == ["assistant"]
|
||||
assert resources[0].annotations.priority == 0.5
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_resource_annotations_with_both_audiences(self):
|
||||
"""Test resource with both user and assistant audience."""
|
||||
|
||||
mcp = MCPServer()
|
||||
|
||||
@mcp.resource("resource://both", annotations=Annotations(audience=["user", "assistant"], priority=1.0))
|
||||
def get_both() -> str: # pragma: no cover
|
||||
return "for everyone"
|
||||
|
||||
resources = await mcp.list_resources()
|
||||
assert resources[0].annotations is not None
|
||||
assert resources[0].annotations.audience == ["user", "assistant"]
|
||||
assert resources[0].annotations.priority == 1.0
|
||||
|
||||
|
||||
class TestAnnotationsValidation:
|
||||
"""Test validation of annotation values."""
|
||||
|
||||
def test_priority_validation(self):
|
||||
"""Test that priority is validated to be between 0.0 and 1.0."""
|
||||
|
||||
# Valid priorities
|
||||
Annotations(priority=0.0)
|
||||
Annotations(priority=0.5)
|
||||
Annotations(priority=1.0)
|
||||
|
||||
# Invalid priorities should raise validation error
|
||||
with pytest.raises(Exception): # Pydantic validation error
|
||||
Annotations(priority=-0.1)
|
||||
|
||||
with pytest.raises(Exception):
|
||||
Annotations(priority=1.1)
|
||||
|
||||
def test_audience_validation(self):
|
||||
"""Test that audience only accepts valid roles."""
|
||||
|
||||
# Valid audiences
|
||||
Annotations(audience=["user"])
|
||||
Annotations(audience=["assistant"])
|
||||
Annotations(audience=["user", "assistant"])
|
||||
Annotations(audience=[])
|
||||
|
||||
# Invalid roles should raise validation error
|
||||
with pytest.raises(Exception): # Pydantic validation error
|
||||
Annotations(audience=["invalid_role"]) # type: ignore
|
||||
|
||||
|
||||
class TestResourceMetadata:
|
||||
"""Test metadata field on base Resource class."""
|
||||
|
||||
def test_resource_with_metadata(self):
|
||||
"""Test that Resource base class accepts meta parameter."""
|
||||
|
||||
def dummy_func() -> str: # pragma: no cover
|
||||
return "data"
|
||||
|
||||
metadata = {"version": "1.0", "category": "test"}
|
||||
|
||||
resource = FunctionResource(
|
||||
uri="resource://test",
|
||||
name="test",
|
||||
fn=dummy_func,
|
||||
meta=metadata,
|
||||
)
|
||||
|
||||
assert resource.meta is not None
|
||||
assert resource.meta == metadata
|
||||
assert resource.meta["version"] == "1.0"
|
||||
assert resource.meta["category"] == "test"
|
||||
|
||||
def test_resource_without_metadata(self):
|
||||
"""Test that meta field defaults to None."""
|
||||
|
||||
def dummy_func() -> str: # pragma: no cover
|
||||
return "data"
|
||||
|
||||
resource = FunctionResource(
|
||||
uri="resource://test",
|
||||
name="test",
|
||||
fn=dummy_func,
|
||||
)
|
||||
|
||||
assert resource.meta is None
|
||||
0
tests/server/mcpserver/servers/__init__.py
Normal file
0
tests/server/mcpserver/servers/__init__.py
Normal file
128
tests/server/mcpserver/servers/test_file_server.py
Normal file
128
tests/server/mcpserver/servers/test_file_server.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def test_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
"""Create a temporary directory with test files."""
|
||||
tmp = tmp_path_factory.mktemp("test_files")
|
||||
|
||||
# Create test files
|
||||
(tmp / "example.py").write_text("print('hello world')")
|
||||
(tmp / "readme.md").write_text("# Test Directory\nThis is a test.")
|
||||
(tmp / "config.json").write_text('{"test": true}')
|
||||
|
||||
return tmp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mcp() -> MCPServer:
|
||||
mcp = MCPServer()
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def resources(mcp: MCPServer, test_dir: Path) -> MCPServer:
|
||||
@mcp.resource("dir://test_dir")
|
||||
def list_test_dir() -> list[str]:
|
||||
"""List the files in the test directory"""
|
||||
return [str(f) for f in test_dir.iterdir()]
|
||||
|
||||
@mcp.resource("file://test_dir/example.py")
|
||||
def read_example_py() -> str:
|
||||
"""Read the example.py file"""
|
||||
try:
|
||||
return (test_dir / "example.py").read_text()
|
||||
except FileNotFoundError:
|
||||
return "File not found"
|
||||
|
||||
@mcp.resource("file://test_dir/readme.md")
|
||||
def read_readme_md() -> str:
|
||||
"""Read the readme.md file"""
|
||||
try: # pragma: no cover
|
||||
return (test_dir / "readme.md").read_text()
|
||||
except FileNotFoundError: # pragma: no cover
|
||||
return "File not found"
|
||||
|
||||
@mcp.resource("file://test_dir/config.json")
|
||||
def read_config_json() -> str:
|
||||
"""Read the config.json file"""
|
||||
try: # pragma: no cover
|
||||
return (test_dir / "config.json").read_text()
|
||||
except FileNotFoundError: # pragma: no cover
|
||||
return "File not found"
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def tools(mcp: MCPServer, test_dir: Path) -> MCPServer:
|
||||
@mcp.tool()
|
||||
def delete_file(path: str) -> bool:
|
||||
# ensure path is in test_dir
|
||||
if Path(path).resolve().parent != test_dir: # pragma: no cover
|
||||
raise ValueError(f"Path must be in test_dir: {path}")
|
||||
Path(path).unlink()
|
||||
return True
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_resources(mcp: MCPServer):
|
||||
resources = await mcp.list_resources()
|
||||
assert len(resources) == 4
|
||||
|
||||
assert [str(r.uri) for r in resources] == [
|
||||
"dir://test_dir",
|
||||
"file://test_dir/example.py",
|
||||
"file://test_dir/readme.md",
|
||||
"file://test_dir/config.json",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_read_resource_dir(mcp: MCPServer):
|
||||
res_iter = await mcp.read_resource("dir://test_dir")
|
||||
res_list = list(res_iter)
|
||||
assert len(res_list) == 1
|
||||
res = res_list[0]
|
||||
assert res.mime_type == "text/plain"
|
||||
|
||||
files = json.loads(res.content)
|
||||
|
||||
assert sorted([Path(f).name for f in files]) == [
|
||||
"config.json",
|
||||
"example.py",
|
||||
"readme.md",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_read_resource_file(mcp: MCPServer):
|
||||
res_iter = await mcp.read_resource("file://test_dir/example.py")
|
||||
res_list = list(res_iter)
|
||||
assert len(res_list) == 1
|
||||
res = res_list[0]
|
||||
assert res.content == "print('hello world')"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_file(mcp: MCPServer, test_dir: Path):
|
||||
await mcp.call_tool("delete_file", arguments={"path": str(test_dir / "example.py")})
|
||||
assert not (test_dir / "example.py").exists()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_file_and_check_resources(mcp: MCPServer, test_dir: Path):
|
||||
await mcp.call_tool("delete_file", arguments={"path": str(test_dir / "example.py")})
|
||||
res_iter = await mcp.read_resource("file://test_dir/example.py")
|
||||
res_list = list(res_iter)
|
||||
assert len(res_list) == 1
|
||||
res = res_list[0]
|
||||
assert res.content == "File not found"
|
||||
392
tests/server/mcpserver/test_elicitation.py
Normal file
392
tests/server/mcpserver/test_elicitation.py
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
"""Test the elicitation feature using stdio transport."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from mcp import Client, types
|
||||
from mcp.client.session import ClientSession, ElicitationFnT
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from mcp.server.session import ServerSession
|
||||
from mcp.shared.context import RequestContext
|
||||
from mcp.types import ElicitRequestParams, ElicitResult, TextContent
|
||||
|
||||
|
||||
# Shared schema for basic tests
|
||||
class AnswerSchema(BaseModel):
|
||||
answer: str = Field(description="The user's answer to the question")
|
||||
|
||||
|
||||
def create_ask_user_tool(mcp: MCPServer):
|
||||
"""Create a standard ask_user tool that handles all elicitation responses."""
|
||||
|
||||
@mcp.tool(description="A tool that uses elicitation")
|
||||
async def ask_user(prompt: str, ctx: Context[ServerSession, None]) -> str:
|
||||
result = await ctx.elicit(message=f"Tool wants to ask: {prompt}", schema=AnswerSchema)
|
||||
|
||||
if result.action == "accept" and result.data:
|
||||
return f"User answered: {result.data.answer}"
|
||||
elif result.action == "decline":
|
||||
return "User declined to answer"
|
||||
else: # pragma: no cover
|
||||
return "User cancelled"
|
||||
|
||||
return ask_user
|
||||
|
||||
|
||||
async def call_tool_and_assert(
|
||||
mcp: MCPServer,
|
||||
elicitation_callback: ElicitationFnT,
|
||||
tool_name: str,
|
||||
args: dict[str, Any],
|
||||
expected_text: str | None = None,
|
||||
text_contains: list[str] | None = None,
|
||||
):
|
||||
"""Helper to create session, call tool, and assert result."""
|
||||
async with Client(mcp, elicitation_callback=elicitation_callback) as client:
|
||||
result = await client.call_tool(tool_name, args)
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
|
||||
if expected_text is not None:
|
||||
assert result.content[0].text == expected_text
|
||||
elif text_contains is not None: # pragma: no branch
|
||||
for substring in text_contains:
|
||||
assert substring in result.content[0].text
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stdio_elicitation():
|
||||
"""Test the elicitation feature using stdio transport."""
|
||||
mcp = MCPServer(name="StdioElicitationServer")
|
||||
create_ask_user_tool(mcp)
|
||||
|
||||
# Create a custom handler for elicitation requests
|
||||
async def elicitation_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams):
|
||||
if params.message == "Tool wants to ask: What is your name?":
|
||||
return ElicitResult(action="accept", content={"answer": "Test User"})
|
||||
else: # pragma: no cover
|
||||
raise ValueError(f"Unexpected elicitation message: {params.message}")
|
||||
|
||||
await call_tool_and_assert(
|
||||
mcp, elicitation_callback, "ask_user", {"prompt": "What is your name?"}, "User answered: Test User"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stdio_elicitation_decline():
|
||||
"""Test elicitation with user declining."""
|
||||
mcp = MCPServer(name="StdioElicitationDeclineServer")
|
||||
create_ask_user_tool(mcp)
|
||||
|
||||
async def elicitation_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams):
|
||||
return ElicitResult(action="decline")
|
||||
|
||||
await call_tool_and_assert(
|
||||
mcp, elicitation_callback, "ask_user", {"prompt": "What is your name?"}, "User declined to answer"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_elicitation_schema_validation():
|
||||
"""Test that elicitation schemas must only contain primitive types."""
|
||||
mcp = MCPServer(name="ValidationTestServer")
|
||||
|
||||
def create_validation_tool(name: str, schema_class: type[BaseModel]):
|
||||
@mcp.tool(name=name, description=f"Tool testing {name}")
|
||||
async def tool(ctx: Context[ServerSession, None]) -> str:
|
||||
try:
|
||||
await ctx.elicit(message="This should fail validation", schema=schema_class)
|
||||
return "Should not reach here" # pragma: no cover
|
||||
except TypeError as e:
|
||||
return f"Validation failed as expected: {str(e)}"
|
||||
|
||||
return tool
|
||||
|
||||
# Test cases for invalid schemas
|
||||
class InvalidListSchema(BaseModel):
|
||||
numbers: list[int] = Field(description="List of numbers")
|
||||
|
||||
class NestedModel(BaseModel):
|
||||
value: str
|
||||
|
||||
class InvalidNestedSchema(BaseModel):
|
||||
nested: NestedModel = Field(description="Nested model")
|
||||
|
||||
create_validation_tool("invalid_list", InvalidListSchema)
|
||||
create_validation_tool("nested_model", InvalidNestedSchema)
|
||||
|
||||
# Dummy callback (won't be called due to validation failure)
|
||||
async def elicitation_callback(
|
||||
context: RequestContext[ClientSession, None], params: ElicitRequestParams
|
||||
): # pragma: no cover
|
||||
return ElicitResult(action="accept", content={})
|
||||
|
||||
async with Client(mcp, elicitation_callback=elicitation_callback) as client:
|
||||
# Test both invalid schemas
|
||||
for tool_name, field_name in [("invalid_list", "numbers"), ("nested_model", "nested")]:
|
||||
result = await client.call_tool(tool_name, {})
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert "Validation failed as expected" in result.content[0].text
|
||||
assert field_name in result.content[0].text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_elicitation_with_optional_fields():
|
||||
"""Test that Optional fields work correctly in elicitation schemas."""
|
||||
mcp = MCPServer(name="OptionalFieldServer")
|
||||
|
||||
class OptionalSchema(BaseModel):
|
||||
required_name: str = Field(description="Your name (required)")
|
||||
optional_age: int | None = Field(default=None, description="Your age (optional)")
|
||||
optional_email: str | None = Field(default=None, description="Your email (optional)")
|
||||
subscribe: bool | None = Field(default=False, description="Subscribe to newsletter?")
|
||||
|
||||
@mcp.tool(description="Tool with optional fields")
|
||||
async def optional_tool(ctx: Context[ServerSession, None]) -> str:
|
||||
result = await ctx.elicit(message="Please provide your information", schema=OptionalSchema)
|
||||
|
||||
if result.action == "accept" and result.data:
|
||||
info = [f"Name: {result.data.required_name}"]
|
||||
if result.data.optional_age is not None:
|
||||
info.append(f"Age: {result.data.optional_age}")
|
||||
if result.data.optional_email is not None:
|
||||
info.append(f"Email: {result.data.optional_email}")
|
||||
info.append(f"Subscribe: {result.data.subscribe}")
|
||||
return ", ".join(info)
|
||||
else: # pragma: no cover
|
||||
return f"User {result.action}"
|
||||
|
||||
# Test cases with different field combinations
|
||||
test_cases: list[tuple[dict[str, Any], str]] = [
|
||||
(
|
||||
# All fields provided
|
||||
{"required_name": "John Doe", "optional_age": 30, "optional_email": "john@example.com", "subscribe": True},
|
||||
"Name: John Doe, Age: 30, Email: john@example.com, Subscribe: True",
|
||||
),
|
||||
(
|
||||
# Only required fields
|
||||
{"required_name": "Jane Smith"},
|
||||
"Name: Jane Smith, Subscribe: False",
|
||||
),
|
||||
]
|
||||
|
||||
for content, expected in test_cases:
|
||||
|
||||
async def callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams):
|
||||
return ElicitResult(action="accept", content=content)
|
||||
|
||||
await call_tool_and_assert(mcp, callback, "optional_tool", {}, expected)
|
||||
|
||||
# Test invalid optional field
|
||||
class InvalidOptionalSchema(BaseModel):
|
||||
name: str = Field(description="Name")
|
||||
optional_list: list[int] | None = Field(default=None, description="Invalid optional list")
|
||||
|
||||
@mcp.tool(description="Tool with invalid optional field")
|
||||
async def invalid_optional_tool(ctx: Context[ServerSession, None]) -> str:
|
||||
try:
|
||||
await ctx.elicit(message="This should fail", schema=InvalidOptionalSchema)
|
||||
return "Should not reach here" # pragma: no cover
|
||||
except TypeError as e:
|
||||
return f"Validation failed: {str(e)}"
|
||||
|
||||
async def elicitation_callback(
|
||||
context: RequestContext[ClientSession, None], params: ElicitRequestParams
|
||||
): # pragma: no cover
|
||||
return ElicitResult(action="accept", content={})
|
||||
|
||||
await call_tool_and_assert(
|
||||
mcp,
|
||||
elicitation_callback,
|
||||
"invalid_optional_tool",
|
||||
{},
|
||||
text_contains=["Validation failed:", "optional_list"],
|
||||
)
|
||||
|
||||
# Test valid list[str] for multi-select enum
|
||||
class ValidMultiSelectSchema(BaseModel):
|
||||
name: str = Field(description="Name")
|
||||
tags: list[str] = Field(description="Tags")
|
||||
|
||||
@mcp.tool(description="Tool with valid list[str] field")
|
||||
async def valid_multiselect_tool(ctx: Context[ServerSession, None]) -> str:
|
||||
result = await ctx.elicit(message="Please provide tags", schema=ValidMultiSelectSchema)
|
||||
if result.action == "accept" and result.data:
|
||||
return f"Name: {result.data.name}, Tags: {', '.join(result.data.tags)}"
|
||||
return f"User {result.action}" # pragma: no cover
|
||||
|
||||
async def multiselect_callback(context: RequestContext[ClientSession, Any], params: ElicitRequestParams):
|
||||
if "Please provide tags" in params.message:
|
||||
return ElicitResult(action="accept", content={"name": "Test", "tags": ["tag1", "tag2"]})
|
||||
return ElicitResult(action="decline") # pragma: no cover
|
||||
|
||||
await call_tool_and_assert(mcp, multiselect_callback, "valid_multiselect_tool", {}, "Name: Test, Tags: tag1, tag2")
|
||||
|
||||
# Test Optional[list[str]] for optional multi-select enum
|
||||
class OptionalMultiSelectSchema(BaseModel):
|
||||
name: str = Field(description="Name")
|
||||
tags: list[str] | None = Field(default=None, description="Optional tags")
|
||||
|
||||
@mcp.tool(description="Tool with optional list[str] field")
|
||||
async def optional_multiselect_tool(ctx: Context[ServerSession, None]) -> str:
|
||||
result = await ctx.elicit(message="Please provide optional tags", schema=OptionalMultiSelectSchema)
|
||||
if result.action == "accept" and result.data:
|
||||
tags_str = ", ".join(result.data.tags) if result.data.tags else "none"
|
||||
return f"Name: {result.data.name}, Tags: {tags_str}"
|
||||
return f"User {result.action}" # pragma: no cover
|
||||
|
||||
async def optional_multiselect_callback(context: RequestContext[ClientSession, Any], params: ElicitRequestParams):
|
||||
if "Please provide optional tags" in params.message:
|
||||
return ElicitResult(action="accept", content={"name": "Test", "tags": ["tag1", "tag2"]})
|
||||
return ElicitResult(action="decline") # pragma: no cover
|
||||
|
||||
await call_tool_and_assert(
|
||||
mcp, optional_multiselect_callback, "optional_multiselect_tool", {}, "Name: Test, Tags: tag1, tag2"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_elicitation_with_default_values():
|
||||
"""Test that default values work correctly in elicitation schemas and are included in JSON."""
|
||||
mcp = MCPServer(name="DefaultValuesServer")
|
||||
|
||||
class DefaultsSchema(BaseModel):
|
||||
name: str = Field(default="Guest", description="User name")
|
||||
age: int = Field(default=18, description="User age")
|
||||
subscribe: bool = Field(default=True, description="Subscribe to newsletter")
|
||||
email: str = Field(description="Email address (required)")
|
||||
|
||||
@mcp.tool(description="Tool with default values")
|
||||
async def defaults_tool(ctx: Context[ServerSession, None]) -> str:
|
||||
result = await ctx.elicit(message="Please provide your information", schema=DefaultsSchema)
|
||||
|
||||
if result.action == "accept" and result.data:
|
||||
return (
|
||||
f"Name: {result.data.name}, Age: {result.data.age}, "
|
||||
f"Subscribe: {result.data.subscribe}, Email: {result.data.email}"
|
||||
)
|
||||
else: # pragma: no cover
|
||||
return f"User {result.action}"
|
||||
|
||||
# First verify that defaults are present in the JSON schema sent to clients
|
||||
async def callback_schema_verify(context: RequestContext[ClientSession, None], params: ElicitRequestParams):
|
||||
# Verify the schema includes defaults
|
||||
assert isinstance(params, types.ElicitRequestFormParams), "Expected form mode elicitation"
|
||||
schema = params.requested_schema
|
||||
props = schema["properties"]
|
||||
|
||||
assert props["name"]["default"] == "Guest"
|
||||
assert props["age"]["default"] == 18
|
||||
assert props["subscribe"]["default"] is True
|
||||
assert "default" not in props["email"] # Required field has no default
|
||||
|
||||
return ElicitResult(action="accept", content={"email": "test@example.com"})
|
||||
|
||||
await call_tool_and_assert(
|
||||
mcp,
|
||||
callback_schema_verify,
|
||||
"defaults_tool",
|
||||
{},
|
||||
"Name: Guest, Age: 18, Subscribe: True, Email: test@example.com",
|
||||
)
|
||||
|
||||
# Test overriding defaults
|
||||
async def callback_override(context: RequestContext[ClientSession, None], params: ElicitRequestParams):
|
||||
return ElicitResult(
|
||||
action="accept", content={"email": "john@example.com", "name": "John", "age": 25, "subscribe": False}
|
||||
)
|
||||
|
||||
await call_tool_and_assert(
|
||||
mcp, callback_override, "defaults_tool", {}, "Name: John, Age: 25, Subscribe: False, Email: john@example.com"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_elicitation_with_enum_titles():
|
||||
"""Test elicitation with enum schemas using oneOf/anyOf for titles."""
|
||||
mcp = MCPServer(name="ColorPreferencesApp")
|
||||
|
||||
# Test single-select with titles using oneOf
|
||||
class FavoriteColorSchema(BaseModel):
|
||||
user_name: str = Field(description="Your name")
|
||||
favorite_color: str = Field(
|
||||
description="Select your favorite color",
|
||||
json_schema_extra={
|
||||
"oneOf": [
|
||||
{"const": "red", "title": "Red"},
|
||||
{"const": "green", "title": "Green"},
|
||||
{"const": "blue", "title": "Blue"},
|
||||
{"const": "yellow", "title": "Yellow"},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
@mcp.tool(description="Single color selection")
|
||||
async def select_favorite_color(ctx: Context[ServerSession, None]) -> str:
|
||||
result = await ctx.elicit(message="Select your favorite color", schema=FavoriteColorSchema)
|
||||
if result.action == "accept" and result.data:
|
||||
return f"User: {result.data.user_name}, Favorite: {result.data.favorite_color}"
|
||||
return f"User {result.action}" # pragma: no cover
|
||||
|
||||
# Test multi-select with titles using anyOf
|
||||
class FavoriteColorsSchema(BaseModel):
|
||||
user_name: str = Field(description="Your name")
|
||||
favorite_colors: list[str] = Field(
|
||||
description="Select your favorite colors",
|
||||
json_schema_extra={
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{"const": "red", "title": "Red"},
|
||||
{"const": "green", "title": "Green"},
|
||||
{"const": "blue", "title": "Blue"},
|
||||
{"const": "yellow", "title": "Yellow"},
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@mcp.tool(description="Multiple color selection")
|
||||
async def select_favorite_colors(ctx: Context[ServerSession, None]) -> str:
|
||||
result = await ctx.elicit(message="Select your favorite colors", schema=FavoriteColorsSchema)
|
||||
if result.action == "accept" and result.data:
|
||||
return f"User: {result.data.user_name}, Colors: {', '.join(result.data.favorite_colors)}"
|
||||
return f"User {result.action}" # pragma: no cover
|
||||
|
||||
# Test legacy enumNames format
|
||||
class LegacyColorSchema(BaseModel):
|
||||
user_name: str = Field(description="Your name")
|
||||
color: str = Field(
|
||||
description="Select a color",
|
||||
json_schema_extra={"enum": ["red", "green", "blue"], "enumNames": ["Red", "Green", "Blue"]},
|
||||
)
|
||||
|
||||
@mcp.tool(description="Legacy enum format")
|
||||
async def select_color_legacy(ctx: Context[ServerSession, None]) -> str:
|
||||
result = await ctx.elicit(message="Select a color (legacy format)", schema=LegacyColorSchema)
|
||||
if result.action == "accept" and result.data:
|
||||
return f"User: {result.data.user_name}, Color: {result.data.color}"
|
||||
return f"User {result.action}" # pragma: no cover
|
||||
|
||||
async def enum_callback(context: RequestContext[ClientSession, Any], params: ElicitRequestParams):
|
||||
if "colors" in params.message and "legacy" not in params.message:
|
||||
return ElicitResult(action="accept", content={"user_name": "Bob", "favorite_colors": ["red", "green"]})
|
||||
elif "color" in params.message:
|
||||
if "legacy" in params.message:
|
||||
return ElicitResult(action="accept", content={"user_name": "Charlie", "color": "green"})
|
||||
else:
|
||||
return ElicitResult(action="accept", content={"user_name": "Alice", "favorite_color": "blue"})
|
||||
return ElicitResult(action="decline") # pragma: no cover
|
||||
|
||||
# Test single-select with titles
|
||||
await call_tool_and_assert(mcp, enum_callback, "select_favorite_color", {}, "User: Alice, Favorite: blue")
|
||||
|
||||
# Test multi-select with titles
|
||||
await call_tool_and_assert(mcp, enum_callback, "select_favorite_colors", {}, "User: Bob, Colors: red, green")
|
||||
|
||||
# Test legacy enumNames format
|
||||
await call_tool_and_assert(mcp, enum_callback, "select_color_legacy", {}, "User: Charlie, Color: green")
|
||||
1191
tests/server/mcpserver/test_func_metadata.py
Normal file
1191
tests/server/mcpserver/test_func_metadata.py
Normal file
File diff suppressed because it is too large
Load diff
680
tests/server/mcpserver/test_integration.py
Normal file
680
tests/server/mcpserver/test_integration.py
Normal file
|
|
@ -0,0 +1,680 @@
|
|||
"""Integration tests for MCPServer server functionality.
|
||||
|
||||
These tests validate the proper functioning of MCPServer features using focused,
|
||||
single-feature servers across different transports (SSE and StreamableHTTP).
|
||||
"""
|
||||
# TODO(Marcelo): The `examples` package is not being imported as package. We need to solve this.
|
||||
# pyright: reportUnknownMemberType=false
|
||||
# pyright: reportMissingImports=false
|
||||
# pyright: reportUnknownVariableType=false
|
||||
# pyright: reportUnknownArgumentType=false
|
||||
|
||||
import json
|
||||
import multiprocessing
|
||||
import socket
|
||||
from collections.abc import Generator
|
||||
|
||||
import pytest
|
||||
import uvicorn
|
||||
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
from examples.snippets.servers import (
|
||||
basic_prompt,
|
||||
basic_resource,
|
||||
basic_tool,
|
||||
completion,
|
||||
elicitation,
|
||||
mcpserver_quickstart,
|
||||
notifications,
|
||||
sampling,
|
||||
structured_output,
|
||||
tool_progress,
|
||||
)
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.client.sse import sse_client
|
||||
from mcp.client.streamable_http import GetSessionIdCallback, streamable_http_client
|
||||
from mcp.shared.context import RequestContext
|
||||
from mcp.shared.message import SessionMessage
|
||||
from mcp.shared.session import RequestResponder
|
||||
from mcp.types import (
|
||||
ClientResult,
|
||||
CreateMessageRequestParams,
|
||||
CreateMessageResult,
|
||||
ElicitRequestParams,
|
||||
ElicitResult,
|
||||
GetPromptResult,
|
||||
InitializeResult,
|
||||
LoggingMessageNotification,
|
||||
LoggingMessageNotificationParams,
|
||||
NotificationParams,
|
||||
ProgressNotification,
|
||||
ProgressNotificationParams,
|
||||
PromptReference,
|
||||
ReadResourceResult,
|
||||
ResourceListChangedNotification,
|
||||
ResourceTemplateReference,
|
||||
ServerNotification,
|
||||
ServerRequest,
|
||||
TextContent,
|
||||
TextResourceContents,
|
||||
ToolListChangedNotification,
|
||||
)
|
||||
from tests.test_helpers import wait_for_server
|
||||
|
||||
|
||||
class NotificationCollector:
|
||||
"""Collects notifications from the server for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self.progress_notifications: list[ProgressNotificationParams] = []
|
||||
self.log_messages: list[LoggingMessageNotificationParams] = []
|
||||
self.resource_notifications: list[NotificationParams | None] = []
|
||||
self.tool_notifications: list[NotificationParams | None] = []
|
||||
|
||||
async def handle_generic_notification(
|
||||
self, message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception
|
||||
) -> None:
|
||||
"""Handle any server notification and route to appropriate handler."""
|
||||
if isinstance(message, ServerNotification): # pragma: no branch
|
||||
if isinstance(message, ProgressNotification):
|
||||
self.progress_notifications.append(message.params)
|
||||
elif isinstance(message, LoggingMessageNotification):
|
||||
self.log_messages.append(message.params)
|
||||
elif isinstance(message, ResourceListChangedNotification):
|
||||
self.resource_notifications.append(message.params)
|
||||
elif isinstance(message, ToolListChangedNotification): # pragma: no cover
|
||||
self.tool_notifications.append(message.params)
|
||||
|
||||
|
||||
# Common fixtures
|
||||
@pytest.fixture
|
||||
def server_port() -> int:
|
||||
"""Get a free port for testing."""
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server_url(server_port: int) -> str:
|
||||
"""Get the server URL for testing."""
|
||||
return f"http://127.0.0.1:{server_port}"
|
||||
|
||||
|
||||
def run_server_with_transport(module_name: str, port: int, transport: str) -> None: # pragma: no cover
|
||||
"""Run server with specified transport."""
|
||||
# Get the MCP instance based on module name
|
||||
if module_name == "basic_tool":
|
||||
mcp = basic_tool.mcp
|
||||
elif module_name == "basic_resource":
|
||||
mcp = basic_resource.mcp
|
||||
elif module_name == "basic_prompt":
|
||||
mcp = basic_prompt.mcp
|
||||
elif module_name == "tool_progress":
|
||||
mcp = tool_progress.mcp
|
||||
elif module_name == "sampling":
|
||||
mcp = sampling.mcp
|
||||
elif module_name == "elicitation":
|
||||
mcp = elicitation.mcp
|
||||
elif module_name == "completion":
|
||||
mcp = completion.mcp
|
||||
elif module_name == "notifications":
|
||||
mcp = notifications.mcp
|
||||
elif module_name == "mcpserver_quickstart":
|
||||
mcp = mcpserver_quickstart.mcp
|
||||
elif module_name == "structured_output":
|
||||
mcp = structured_output.mcp
|
||||
else:
|
||||
raise ImportError(f"Unknown module: {module_name}")
|
||||
|
||||
# Create app based on transport type
|
||||
if transport == "sse":
|
||||
app = mcp.sse_app()
|
||||
elif transport == "streamable-http":
|
||||
app = mcp.streamable_http_app()
|
||||
else:
|
||||
raise ValueError(f"Invalid transport for test server: {transport}")
|
||||
|
||||
server = uvicorn.Server(config=uvicorn.Config(app=app, host="127.0.0.1", port=port, log_level="error"))
|
||||
print(f"Starting {transport} server on port {port}")
|
||||
server.run()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server_transport(request: pytest.FixtureRequest, server_port: int) -> Generator[str, None, None]:
|
||||
"""Start server in a separate process with specified MCP instance and transport.
|
||||
|
||||
Args:
|
||||
request: pytest request with param tuple of (module_name, transport)
|
||||
server_port: Port to run the server on
|
||||
|
||||
Yields:
|
||||
str: The transport type ('sse' or 'streamable_http')
|
||||
"""
|
||||
module_name, transport = request.param
|
||||
|
||||
proc = multiprocessing.Process(
|
||||
target=run_server_with_transport,
|
||||
args=(module_name, server_port, transport),
|
||||
daemon=True,
|
||||
)
|
||||
proc.start()
|
||||
|
||||
# Wait for server to be ready
|
||||
wait_for_server(server_port)
|
||||
|
||||
yield transport
|
||||
|
||||
proc.kill()
|
||||
proc.join(timeout=2)
|
||||
if proc.is_alive(): # pragma: no cover
|
||||
print("Server process failed to terminate")
|
||||
|
||||
|
||||
# Helper function to create client based on transport
|
||||
def create_client_for_transport(transport: str, server_url: str):
|
||||
"""Create the appropriate client context manager based on transport type."""
|
||||
if transport == "sse":
|
||||
endpoint = f"{server_url}/sse"
|
||||
return sse_client(endpoint)
|
||||
elif transport == "streamable-http":
|
||||
endpoint = f"{server_url}/mcp"
|
||||
return streamable_http_client(endpoint)
|
||||
else: # pragma: no cover
|
||||
raise ValueError(f"Invalid transport: {transport}")
|
||||
|
||||
|
||||
def unpack_streams(
|
||||
client_streams: tuple[MemoryObjectReceiveStream[SessionMessage | Exception], MemoryObjectSendStream[SessionMessage]]
|
||||
| tuple[
|
||||
MemoryObjectReceiveStream[SessionMessage | Exception],
|
||||
MemoryObjectSendStream[SessionMessage],
|
||||
GetSessionIdCallback,
|
||||
],
|
||||
):
|
||||
"""Unpack client streams handling different return values from SSE vs StreamableHTTP.
|
||||
|
||||
SSE client returns (read_stream, write_stream)
|
||||
StreamableHTTP client returns (read_stream, write_stream, session_id_callback)
|
||||
|
||||
Args:
|
||||
client_streams: Tuple from client context manager
|
||||
|
||||
Returns:
|
||||
Tuple of (read_stream, write_stream)
|
||||
"""
|
||||
if len(client_streams) == 2:
|
||||
return client_streams
|
||||
else:
|
||||
read_stream, write_stream, _ = client_streams
|
||||
return read_stream, write_stream
|
||||
|
||||
|
||||
# Callback functions for testing
|
||||
async def sampling_callback(
|
||||
context: RequestContext[ClientSession, None], params: CreateMessageRequestParams
|
||||
) -> CreateMessageResult:
|
||||
"""Sampling callback for tests."""
|
||||
return CreateMessageResult(
|
||||
role="assistant",
|
||||
content=TextContent(
|
||||
type="text",
|
||||
text="This is a simulated LLM response for testing",
|
||||
),
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
|
||||
async def elicitation_callback(context: RequestContext[ClientSession, None], params: ElicitRequestParams):
|
||||
"""Elicitation callback for tests."""
|
||||
# For restaurant booking test
|
||||
if "No tables available" in params.message:
|
||||
return ElicitResult(
|
||||
action="accept",
|
||||
content={"checkAlternative": True, "alternativeDate": "2024-12-26"},
|
||||
)
|
||||
else: # pragma: no cover
|
||||
return ElicitResult(action="decline")
|
||||
|
||||
|
||||
# Test basic tools
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"server_transport",
|
||||
[
|
||||
("basic_tool", "sse"),
|
||||
("basic_tool", "streamable-http"),
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
async def test_basic_tools(server_transport: str, server_url: str) -> None:
|
||||
"""Test basic tool functionality."""
|
||||
transport = server_transport
|
||||
client_cm = create_client_for_transport(transport, server_url)
|
||||
|
||||
async with client_cm as client_streams:
|
||||
read_stream, write_stream = unpack_streams(client_streams)
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
# Test initialization
|
||||
result = await session.initialize()
|
||||
assert isinstance(result, InitializeResult)
|
||||
assert result.server_info.name == "Tool Example"
|
||||
assert result.capabilities.tools is not None
|
||||
|
||||
# Test sum tool
|
||||
tool_result = await session.call_tool("sum", {"a": 5, "b": 3})
|
||||
assert len(tool_result.content) == 1
|
||||
assert isinstance(tool_result.content[0], TextContent)
|
||||
assert tool_result.content[0].text == "8"
|
||||
|
||||
# Test weather tool
|
||||
weather_result = await session.call_tool("get_weather", {"city": "London"})
|
||||
assert len(weather_result.content) == 1
|
||||
assert isinstance(weather_result.content[0], TextContent)
|
||||
assert "Weather in London: 22degreesC" in weather_result.content[0].text
|
||||
|
||||
|
||||
# Test resources
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"server_transport",
|
||||
[
|
||||
("basic_resource", "sse"),
|
||||
("basic_resource", "streamable-http"),
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
async def test_basic_resources(server_transport: str, server_url: str) -> None:
|
||||
"""Test basic resource functionality."""
|
||||
transport = server_transport
|
||||
client_cm = create_client_for_transport(transport, server_url)
|
||||
|
||||
async with client_cm as client_streams:
|
||||
read_stream, write_stream = unpack_streams(client_streams)
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
# Test initialization
|
||||
result = await session.initialize()
|
||||
assert isinstance(result, InitializeResult)
|
||||
assert result.server_info.name == "Resource Example"
|
||||
assert result.capabilities.resources is not None
|
||||
|
||||
# Test document resource
|
||||
doc_content = await session.read_resource("file://documents/readme")
|
||||
assert isinstance(doc_content, ReadResourceResult)
|
||||
assert len(doc_content.contents) == 1
|
||||
assert isinstance(doc_content.contents[0], TextResourceContents)
|
||||
assert "Content of readme" in doc_content.contents[0].text
|
||||
|
||||
# Test settings resource
|
||||
settings_content = await session.read_resource("config://settings")
|
||||
assert isinstance(settings_content, ReadResourceResult)
|
||||
assert len(settings_content.contents) == 1
|
||||
assert isinstance(settings_content.contents[0], TextResourceContents)
|
||||
settings_json = json.loads(settings_content.contents[0].text)
|
||||
assert settings_json["theme"] == "dark"
|
||||
assert settings_json["language"] == "en"
|
||||
|
||||
|
||||
# Test prompts
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"server_transport",
|
||||
[
|
||||
("basic_prompt", "sse"),
|
||||
("basic_prompt", "streamable-http"),
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
async def test_basic_prompts(server_transport: str, server_url: str) -> None:
|
||||
"""Test basic prompt functionality."""
|
||||
transport = server_transport
|
||||
client_cm = create_client_for_transport(transport, server_url)
|
||||
|
||||
async with client_cm as client_streams:
|
||||
read_stream, write_stream = unpack_streams(client_streams)
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
# Test initialization
|
||||
result = await session.initialize()
|
||||
assert isinstance(result, InitializeResult)
|
||||
assert result.server_info.name == "Prompt Example"
|
||||
assert result.capabilities.prompts is not None
|
||||
|
||||
# Test review_code prompt
|
||||
prompts = await session.list_prompts()
|
||||
review_prompt = next((p for p in prompts.prompts if p.name == "review_code"), None)
|
||||
assert review_prompt is not None
|
||||
|
||||
prompt_result = await session.get_prompt("review_code", {"code": "def hello():\n print('Hello')"})
|
||||
assert isinstance(prompt_result, GetPromptResult)
|
||||
assert len(prompt_result.messages) == 1
|
||||
assert isinstance(prompt_result.messages[0].content, TextContent)
|
||||
assert "Please review this code:" in prompt_result.messages[0].content.text
|
||||
assert "def hello():" in prompt_result.messages[0].content.text
|
||||
|
||||
# Test debug_error prompt
|
||||
debug_result = await session.get_prompt(
|
||||
"debug_error", {"error": "TypeError: 'NoneType' object is not subscriptable"}
|
||||
)
|
||||
assert isinstance(debug_result, GetPromptResult)
|
||||
assert len(debug_result.messages) == 3
|
||||
assert debug_result.messages[0].role == "user"
|
||||
assert isinstance(debug_result.messages[0].content, TextContent)
|
||||
assert "I'm seeing this error:" in debug_result.messages[0].content.text
|
||||
assert debug_result.messages[1].role == "user"
|
||||
assert isinstance(debug_result.messages[1].content, TextContent)
|
||||
assert "TypeError" in debug_result.messages[1].content.text
|
||||
assert debug_result.messages[2].role == "assistant"
|
||||
assert isinstance(debug_result.messages[2].content, TextContent)
|
||||
assert "I'll help debug that" in debug_result.messages[2].content.text
|
||||
|
||||
|
||||
# Test progress reporting
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"server_transport",
|
||||
[
|
||||
("tool_progress", "sse"),
|
||||
("tool_progress", "streamable-http"),
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
async def test_tool_progress(server_transport: str, server_url: str) -> None:
|
||||
"""Test tool progress reporting."""
|
||||
transport = server_transport
|
||||
collector = NotificationCollector()
|
||||
|
||||
async def message_handler(message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception):
|
||||
await collector.handle_generic_notification(message)
|
||||
if isinstance(message, Exception): # pragma: no cover
|
||||
raise message
|
||||
|
||||
client_cm = create_client_for_transport(transport, server_url)
|
||||
|
||||
async with client_cm as client_streams:
|
||||
read_stream, write_stream = unpack_streams(client_streams)
|
||||
async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session:
|
||||
# Test initialization
|
||||
result = await session.initialize()
|
||||
assert isinstance(result, InitializeResult)
|
||||
assert result.server_info.name == "Progress Example"
|
||||
|
||||
# Test progress callback
|
||||
progress_updates = []
|
||||
|
||||
async def progress_callback(progress: float, total: float | None, message: str | None) -> None:
|
||||
progress_updates.append((progress, total, message))
|
||||
|
||||
# Call tool with progress
|
||||
steps = 3
|
||||
tool_result = await session.call_tool(
|
||||
"long_running_task",
|
||||
{"task_name": "Test Task", "steps": steps},
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
assert tool_result.content == snapshot([TextContent(text="Task 'Test Task' completed")])
|
||||
|
||||
# Verify progress updates
|
||||
assert len(progress_updates) == steps
|
||||
for i, (progress, total, message) in enumerate(progress_updates):
|
||||
expected_progress = (i + 1) / steps
|
||||
assert abs(progress - expected_progress) < 0.01
|
||||
assert total == 1.0
|
||||
assert f"Step {i + 1}/{steps}" in message
|
||||
|
||||
# Verify log messages
|
||||
assert len(collector.log_messages) > 0
|
||||
|
||||
|
||||
# Test sampling
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"server_transport",
|
||||
[
|
||||
("sampling", "sse"),
|
||||
("sampling", "streamable-http"),
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
async def test_sampling(server_transport: str, server_url: str) -> None:
|
||||
"""Test sampling (LLM interaction) functionality."""
|
||||
transport = server_transport
|
||||
client_cm = create_client_for_transport(transport, server_url)
|
||||
|
||||
async with client_cm as client_streams:
|
||||
read_stream, write_stream = unpack_streams(client_streams)
|
||||
async with ClientSession(read_stream, write_stream, sampling_callback=sampling_callback) as session:
|
||||
# Test initialization
|
||||
result = await session.initialize()
|
||||
assert isinstance(result, InitializeResult)
|
||||
assert result.server_info.name == "Sampling Example"
|
||||
assert result.capabilities.tools is not None
|
||||
|
||||
# Test sampling tool
|
||||
sampling_result = await session.call_tool("generate_poem", {"topic": "nature"})
|
||||
assert len(sampling_result.content) == 1
|
||||
assert isinstance(sampling_result.content[0], TextContent)
|
||||
assert "This is a simulated LLM response" in sampling_result.content[0].text
|
||||
|
||||
|
||||
# Test elicitation
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"server_transport",
|
||||
[
|
||||
("elicitation", "sse"),
|
||||
("elicitation", "streamable-http"),
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
async def test_elicitation(server_transport: str, server_url: str) -> None:
|
||||
"""Test elicitation (user interaction) functionality."""
|
||||
transport = server_transport
|
||||
client_cm = create_client_for_transport(transport, server_url)
|
||||
|
||||
async with client_cm as client_streams:
|
||||
read_stream, write_stream = unpack_streams(client_streams)
|
||||
async with ClientSession(read_stream, write_stream, elicitation_callback=elicitation_callback) as session:
|
||||
# Test initialization
|
||||
result = await session.initialize()
|
||||
assert isinstance(result, InitializeResult)
|
||||
assert result.server_info.name == "Elicitation Example"
|
||||
|
||||
# Test booking with unavailable date (triggers elicitation)
|
||||
booking_result = await session.call_tool(
|
||||
"book_table",
|
||||
{
|
||||
"date": "2024-12-25", # Unavailable date
|
||||
"time": "19:00",
|
||||
"party_size": 4,
|
||||
},
|
||||
)
|
||||
assert len(booking_result.content) == 1
|
||||
assert isinstance(booking_result.content[0], TextContent)
|
||||
assert "[SUCCESS] Booked for 2024-12-26" in booking_result.content[0].text
|
||||
|
||||
# Test booking with available date (no elicitation)
|
||||
booking_result = await session.call_tool(
|
||||
"book_table",
|
||||
{
|
||||
"date": "2024-12-20", # Available date
|
||||
"time": "20:00",
|
||||
"party_size": 2,
|
||||
},
|
||||
)
|
||||
assert len(booking_result.content) == 1
|
||||
assert isinstance(booking_result.content[0], TextContent)
|
||||
assert "[SUCCESS] Booked for 2024-12-20 at 20:00" in booking_result.content[0].text
|
||||
|
||||
|
||||
# Test notifications
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"server_transport",
|
||||
[
|
||||
("notifications", "sse"),
|
||||
("notifications", "streamable-http"),
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
async def test_notifications(server_transport: str, server_url: str) -> None:
|
||||
"""Test notifications and logging functionality."""
|
||||
transport = server_transport
|
||||
collector = NotificationCollector()
|
||||
|
||||
async def message_handler(message: RequestResponder[ServerRequest, ClientResult] | ServerNotification | Exception):
|
||||
await collector.handle_generic_notification(message)
|
||||
if isinstance(message, Exception): # pragma: no cover
|
||||
raise message
|
||||
|
||||
client_cm = create_client_for_transport(transport, server_url)
|
||||
|
||||
async with client_cm as client_streams:
|
||||
read_stream, write_stream = unpack_streams(client_streams)
|
||||
async with ClientSession(read_stream, write_stream, message_handler=message_handler) as session:
|
||||
# Test initialization
|
||||
result = await session.initialize()
|
||||
assert isinstance(result, InitializeResult)
|
||||
assert result.server_info.name == "Notifications Example"
|
||||
|
||||
# Call tool that generates notifications
|
||||
tool_result = await session.call_tool("process_data", {"data": "test_data"})
|
||||
assert len(tool_result.content) == 1
|
||||
assert isinstance(tool_result.content[0], TextContent)
|
||||
assert "Processed: test_data" in tool_result.content[0].text
|
||||
|
||||
# Verify log messages at different levels
|
||||
assert len(collector.log_messages) >= 4
|
||||
log_levels = {msg.level for msg in collector.log_messages}
|
||||
assert "debug" in log_levels
|
||||
assert "info" in log_levels
|
||||
assert "warning" in log_levels
|
||||
assert "error" in log_levels
|
||||
|
||||
# Verify resource list changed notification
|
||||
assert len(collector.resource_notifications) > 0
|
||||
|
||||
|
||||
# Test completion
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"server_transport",
|
||||
[
|
||||
("completion", "sse"),
|
||||
("completion", "streamable-http"),
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
async def test_completion(server_transport: str, server_url: str) -> None:
|
||||
"""Test completion (autocomplete) functionality."""
|
||||
transport = server_transport
|
||||
client_cm = create_client_for_transport(transport, server_url)
|
||||
|
||||
async with client_cm as client_streams:
|
||||
read_stream, write_stream = unpack_streams(client_streams)
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
# Test initialization
|
||||
result = await session.initialize()
|
||||
assert isinstance(result, InitializeResult)
|
||||
assert result.server_info.name == "Example"
|
||||
assert result.capabilities.resources is not None
|
||||
assert result.capabilities.prompts is not None
|
||||
|
||||
# Test resource completion
|
||||
completion_result = await session.complete(
|
||||
ref=ResourceTemplateReference(type="ref/resource", uri="github://repos/{owner}/{repo}"),
|
||||
argument={"name": "repo", "value": ""},
|
||||
context_arguments={"owner": "modelcontextprotocol"},
|
||||
)
|
||||
|
||||
assert completion_result is not None
|
||||
assert hasattr(completion_result, "completion")
|
||||
assert completion_result.completion is not None
|
||||
assert len(completion_result.completion.values) == 3
|
||||
assert "python-sdk" in completion_result.completion.values
|
||||
assert "typescript-sdk" in completion_result.completion.values
|
||||
assert "specification" in completion_result.completion.values
|
||||
|
||||
# Test prompt completion
|
||||
completion_result = await session.complete(
|
||||
ref=PromptReference(type="ref/prompt", name="review_code"),
|
||||
argument={"name": "language", "value": "py"},
|
||||
)
|
||||
|
||||
assert completion_result is not None
|
||||
assert hasattr(completion_result, "completion")
|
||||
assert completion_result.completion is not None
|
||||
assert "python" in completion_result.completion.values
|
||||
assert all(lang.startswith("py") for lang in completion_result.completion.values)
|
||||
|
||||
|
||||
# Test MCPServer quickstart example
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"server_transport",
|
||||
[
|
||||
("mcpserver_quickstart", "sse"),
|
||||
("mcpserver_quickstart", "streamable-http"),
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
async def test_mcpserver_quickstart(server_transport: str, server_url: str) -> None:
|
||||
"""Test MCPServer quickstart example."""
|
||||
transport = server_transport
|
||||
client_cm = create_client_for_transport(transport, server_url)
|
||||
|
||||
async with client_cm as client_streams:
|
||||
read_stream, write_stream = unpack_streams(client_streams)
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
# Test initialization
|
||||
result = await session.initialize()
|
||||
assert isinstance(result, InitializeResult)
|
||||
assert result.server_info.name == "Demo"
|
||||
|
||||
# Test add tool
|
||||
tool_result = await session.call_tool("add", {"a": 10, "b": 20})
|
||||
assert len(tool_result.content) == 1
|
||||
assert isinstance(tool_result.content[0], TextContent)
|
||||
assert tool_result.content[0].text == "30"
|
||||
|
||||
# Test greeting resource directly
|
||||
resource_result = await session.read_resource("greeting://Alice")
|
||||
assert len(resource_result.contents) == 1
|
||||
assert isinstance(resource_result.contents[0], TextResourceContents)
|
||||
assert resource_result.contents[0].text == "Hello, Alice!"
|
||||
|
||||
|
||||
# Test structured output example
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"server_transport",
|
||||
[
|
||||
("structured_output", "sse"),
|
||||
("structured_output", "streamable-http"),
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
async def test_structured_output(server_transport: str, server_url: str) -> None:
|
||||
"""Test structured output functionality."""
|
||||
transport = server_transport
|
||||
client_cm = create_client_for_transport(transport, server_url)
|
||||
|
||||
async with client_cm as client_streams:
|
||||
read_stream, write_stream = unpack_streams(client_streams)
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
# Test initialization
|
||||
result = await session.initialize()
|
||||
assert isinstance(result, InitializeResult)
|
||||
assert result.server_info.name == "Structured Output Example"
|
||||
|
||||
# Test get_weather tool
|
||||
weather_result = await session.call_tool("get_weather", {"city": "New York"})
|
||||
assert len(weather_result.content) == 1
|
||||
assert isinstance(weather_result.content[0], TextContent)
|
||||
|
||||
# Check that the result contains expected weather data
|
||||
result_text = weather_result.content[0].text
|
||||
assert "22.5" in result_text # temperature
|
||||
assert "sunny" in result_text # condition
|
||||
assert "45" in result_text # humidity
|
||||
assert "5.2" in result_text # wind_speed
|
||||
30
tests/server/mcpserver/test_parameter_descriptions.py
Normal file
30
tests/server/mcpserver/test_parameter_descriptions.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"""Test that parameter descriptions are properly exposed through list_tools"""
|
||||
|
||||
import pytest
|
||||
from pydantic import Field
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_parameter_descriptions():
|
||||
mcp = MCPServer("Test Server")
|
||||
|
||||
@mcp.tool()
|
||||
def greet(
|
||||
name: str = Field(description="The name to greet"),
|
||||
title: str = Field(description="Optional title", default=""),
|
||||
) -> str: # pragma: no cover
|
||||
"""A greeting tool"""
|
||||
return f"Hello {title} {name}"
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
assert len(tools) == 1
|
||||
tool = tools[0]
|
||||
|
||||
# Check that parameter descriptions are present in the schema
|
||||
properties = tool.input_schema["properties"]
|
||||
assert "name" in properties
|
||||
assert properties["name"]["description"] == "The name to greet"
|
||||
assert "title" in properties
|
||||
assert properties["title"]["description"] == "Optional title"
|
||||
1502
tests/server/mcpserver/test_server.py
Normal file
1502
tests/server/mcpserver/test_server.py
Normal file
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue