chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Tests for the MCP server auth components."""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,245 @@
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from mcp_types import (
|
||||
ElicitRequest,
|
||||
ElicitRequestFormParams,
|
||||
EmbeddedResource,
|
||||
InputRequiredResult,
|
||||
TextContent,
|
||||
TextResourceContents,
|
||||
)
|
||||
|
||||
from mcp.server.mcpserver import Context
|
||||
from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, Prompt, UserMessage
|
||||
|
||||
|
||||
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(None, Context()) == [
|
||||
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(None, Context()) == [
|
||||
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({"name": "World"}, Context()) == [
|
||||
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({"age": 40}, Context())
|
||||
|
||||
@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(None, Context()) == [
|
||||
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(None, Context()) == [
|
||||
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(None, Context()) == 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(None, Context()) == [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(None, Context()) == [
|
||||
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(None, Context()) == [
|
||||
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(None, Context()) == [
|
||||
UserMessage(
|
||||
content=EmbeddedResource(
|
||||
type="resource",
|
||||
resource=TextResourceContents(
|
||||
uri="file://file.txt",
|
||||
text="File contents",
|
||||
mime_type="text/plain",
|
||||
),
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sync_fn_runs_in_worker_thread():
|
||||
"""Sync prompt functions must run in a worker thread, not the event loop."""
|
||||
|
||||
main_thread = threading.get_ident()
|
||||
fn_thread: list[int] = []
|
||||
|
||||
def blocking_fn() -> str:
|
||||
fn_thread.append(threading.get_ident())
|
||||
return "hello"
|
||||
|
||||
prompt = Prompt.from_function(blocking_fn)
|
||||
messages = await prompt.render(None, Context())
|
||||
|
||||
assert messages == [UserMessage(content=TextContent(type="text", text="hello"))]
|
||||
assert fn_thread[0] != main_thread
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_render_passes_input_required_result_through_unchanged():
|
||||
"""Prompt.render returns the InputRequiredResult the function returned, bypassing
|
||||
message conversion entirely (SEP-2322 multi-round-trip pass-through)."""
|
||||
sentinel = InputRequiredResult(
|
||||
input_requests={
|
||||
"who": ElicitRequest(
|
||||
params=ElicitRequestFormParams(
|
||||
message="Who is this for?",
|
||||
requested_schema={
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
"required": ["name"],
|
||||
},
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
def asking_prompt() -> InputRequiredResult:
|
||||
return sentinel
|
||||
|
||||
prompt = Prompt.from_function(asking_prompt)
|
||||
result = await prompt.render(None, Context())
|
||||
assert result is sentinel
|
||||
@@ -0,0 +1,110 @@
|
||||
import pytest
|
||||
from mcp_types import TextContent
|
||||
|
||||
from mcp.server.mcpserver import Context
|
||||
from mcp.server.mcpserver.prompts.base import Prompt, 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", None, Context())
|
||||
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", {"name": "World"}, Context())
|
||||
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", None, Context())
|
||||
|
||||
@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", None, Context())
|
||||
@@ -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
|
||||
@@ -0,0 +1,263 @@
|
||||
import threading
|
||||
|
||||
import anyio
|
||||
import anyio.from_thread
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import InputRequiredResult
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sync_fn_runs_in_worker_thread():
|
||||
"""Sync resource functions must run in a worker thread, not the event loop."""
|
||||
|
||||
main_thread = threading.get_ident()
|
||||
fn_thread: list[int] = []
|
||||
|
||||
def blocking_fn() -> str:
|
||||
fn_thread.append(threading.get_ident())
|
||||
return "data"
|
||||
|
||||
resource = FunctionResource(uri="resource://test", name="test", fn=blocking_fn)
|
||||
result = await resource.read()
|
||||
|
||||
assert result == "data"
|
||||
assert fn_thread[0] != main_thread
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sync_fn_does_not_block_event_loop():
|
||||
"""A blocking sync resource function must not stall the event loop.
|
||||
|
||||
On regression (sync runs inline), anyio.from_thread.run_sync raises
|
||||
RuntimeError because there is no worker-thread context, failing fast.
|
||||
"""
|
||||
handler_entered = anyio.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def blocking_fn() -> str:
|
||||
anyio.from_thread.run_sync(handler_entered.set)
|
||||
release.wait()
|
||||
return "done"
|
||||
|
||||
resource = FunctionResource(uri="resource://test", name="test", fn=blocking_fn)
|
||||
result: list[str | bytes] = []
|
||||
|
||||
async def run() -> None:
|
||||
result.append(await resource.read())
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(run)
|
||||
await handler_entered.wait()
|
||||
release.set()
|
||||
|
||||
assert result == ["done"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_read_rejects_an_input_required_result_from_a_static_function():
|
||||
"""A static resource function returning an InputRequiredResult is a mistake (it can
|
||||
never read the retry's input_responses), so read() raises instead of JSON-dumping it."""
|
||||
|
||||
def ask() -> InputRequiredResult:
|
||||
return InputRequiredResult(request_state="round-1")
|
||||
|
||||
resource = FunctionResource(uri="resource://ask", name="ask", fn=ask)
|
||||
with pytest.raises(ValueError) as exc:
|
||||
await resource.read()
|
||||
assert str(exc.value) == snapshot(
|
||||
"Error reading resource resource://ask: static resources cannot return "
|
||||
"InputRequiredResult; only resource template functions participate in the multi-round-trip flow"
|
||||
)
|
||||
@@ -0,0 +1,142 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import AnyUrl
|
||||
|
||||
from mcp.server.mcpserver import Context
|
||||
from mcp.server.mcpserver.exceptions import ResourceNotFoundError
|
||||
from mcp.server.mcpserver.resources import FileResource, FunctionResource, ResourceManager, ResourceTemplate
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def temp_file(tmp_path: Path):
|
||||
"""Create a temporary file for testing.
|
||||
|
||||
File is automatically cleaned up after the test if it still exists.
|
||||
"""
|
||||
tmp_file = tmp_path / "file"
|
||||
tmp_file.touch()
|
||||
yield tmp_file
|
||||
|
||||
|
||||
def test_init_with_resources(temp_file: Path, caplog: pytest.LogCaptureFixture):
|
||||
resource = FileResource(uri=f"file://{temp_file}", name="test", path=temp_file)
|
||||
manager = ResourceManager(resources=[resource])
|
||||
assert manager.list_resources() == [resource]
|
||||
|
||||
duplicate_resource = FileResource(uri=f"file://{temp_file}", name="duplicate", path=temp_file)
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
manager = ResourceManager(True, resources=[resource, duplicate_resource])
|
||||
|
||||
assert "Resource already exists" in caplog.text
|
||||
assert manager.list_resources() == [resource]
|
||||
|
||||
|
||||
def test_add_resource(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(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(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(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(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, Context())
|
||||
assert retrieved == resource
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_resource_from_template():
|
||||
"""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"), Context())
|
||||
assert isinstance(resource, FunctionResource)
|
||||
content = await resource.read()
|
||||
assert content == "Hello, world!"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_unknown_resource():
|
||||
"""Test getting a non-existent resource."""
|
||||
manager = ResourceManager()
|
||||
with pytest.raises(ResourceNotFoundError, match="Unknown resource"):
|
||||
await manager.get_resource(AnyUrl("unknown://test"), Context())
|
||||
|
||||
|
||||
def test_list_resources(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]
|
||||
|
||||
|
||||
def get_item(id: str) -> str: ...
|
||||
|
||||
|
||||
def test_add_template_with_metadata():
|
||||
"""Test that ResourceManager.add_template() accepts and passes meta parameter."""
|
||||
manager = ResourceManager()
|
||||
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():
|
||||
"""Test that ResourceManager.add_template() works without meta parameter."""
|
||||
manager = ResourceManager()
|
||||
template = manager.add_template(fn=get_item, uri_template="resource://items/{id}")
|
||||
assert template.meta is None
|
||||
@@ -0,0 +1,507 @@
|
||||
import json
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from mcp_types import Annotations, ElicitRequest, ElicitRequestFormParams, InputRequiredResult
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from mcp.server.mcpserver.exceptions import ResourceError
|
||||
from mcp.server.mcpserver.resources import FunctionResource, ResourceTemplate
|
||||
from mcp.server.mcpserver.resources.templates import (
|
||||
DEFAULT_RESOURCE_SECURITY,
|
||||
ResourceSecurity,
|
||||
ResourceSecurityError,
|
||||
)
|
||||
|
||||
|
||||
def _make(uri_template: str, security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY) -> ResourceTemplate:
|
||||
def handler(**kwargs: Any) -> str:
|
||||
raise NotImplementedError # these tests only exercise matches()
|
||||
|
||||
return ResourceTemplate.from_function(fn=handler, uri_template=uri_template, security=security)
|
||||
|
||||
|
||||
def test_matches_rfc6570_reserved_expansion():
|
||||
# {+path} allows / — the feature the old regex implementation couldn't support
|
||||
t = _make("file://docs/{+path}")
|
||||
assert t.matches("file://docs/src/main.py") == {"path": "src/main.py"}
|
||||
|
||||
|
||||
def test_matches_rejects_encoded_slash_traversal():
|
||||
# %2F decodes to / in UriTemplate.match(), giving "../../etc/passwd".
|
||||
# ResourceSecurity's traversal check then rejects the '..' components.
|
||||
t = _make("file://docs/{name}")
|
||||
with pytest.raises(ResourceSecurityError, match="'name'"):
|
||||
t.matches("file://docs/..%2F..%2Fetc%2Fpasswd")
|
||||
|
||||
|
||||
def test_matches_rejects_path_traversal_by_default():
|
||||
t = _make("file://docs/{name}")
|
||||
with pytest.raises(ResourceSecurityError):
|
||||
t.matches("file://docs/..")
|
||||
|
||||
|
||||
def test_matches_rejects_path_traversal_in_reserved_var():
|
||||
# Even {+path} gets the traversal check — it's semantic, not structural
|
||||
t = _make("file://docs/{+path}")
|
||||
with pytest.raises(ResourceSecurityError):
|
||||
t.matches("file://docs/../../etc/passwd")
|
||||
|
||||
|
||||
def test_matches_rejects_absolute_path():
|
||||
t = _make("file://docs/{+path}")
|
||||
with pytest.raises(ResourceSecurityError):
|
||||
t.matches("file://docs//etc/passwd")
|
||||
|
||||
|
||||
def test_matches_allows_dotdot_as_substring():
|
||||
# .. is only dangerous as a path component
|
||||
t = _make("git://refs/{range}")
|
||||
assert t.matches("git://refs/v1.0..v2.0") == {"range": "v1.0..v2.0"}
|
||||
|
||||
|
||||
def test_matches_exempt_params_skip_security():
|
||||
policy = ResourceSecurity(exempt_params={"range"})
|
||||
t = _make("git://diff/{+range}", security=policy)
|
||||
assert t.matches("git://diff/../foo") == {"range": "../foo"}
|
||||
|
||||
|
||||
def test_matches_disabled_policy_allows_traversal():
|
||||
policy = ResourceSecurity(reject_path_traversal=False, reject_absolute_paths=False)
|
||||
t = _make("file://docs/{name}", security=policy)
|
||||
assert t.matches("file://docs/..") == {"name": ".."}
|
||||
|
||||
|
||||
def test_matches_rejects_null_byte_by_default():
|
||||
# %00 decodes to \x00 which defeats string comparisons
|
||||
# ("..\x00" != "..") and can truncate in C extensions.
|
||||
t = _make("file://docs/{name}")
|
||||
with pytest.raises(ResourceSecurityError):
|
||||
t.matches("file://docs/key%00.txt")
|
||||
# Null byte also defeats the traversal check's component comparison
|
||||
with pytest.raises(ResourceSecurityError):
|
||||
t.matches("file://docs/..%00%2Fsecret")
|
||||
|
||||
|
||||
def test_matches_null_byte_check_can_be_disabled():
|
||||
policy = ResourceSecurity(reject_null_bytes=False)
|
||||
t = _make("file://docs/{name}", security=policy)
|
||||
assert t.matches("file://docs/key%00.txt") == {"name": "key\x00.txt"}
|
||||
|
||||
|
||||
def test_security_rejection_does_not_fall_through_to_next_template():
|
||||
# A strict template's security rejection must halt iteration, not
|
||||
# fall through to a later permissive template. Previously matches()
|
||||
# returned None for both "no match" and "security failed", making
|
||||
# registration order security-critical.
|
||||
strict = _make("file://docs/{name}")
|
||||
lax = _make(
|
||||
"file://docs/{+path}",
|
||||
security=ResourceSecurity(exempt_params={"path"}),
|
||||
)
|
||||
uri = "file://docs/..%2Fsecrets"
|
||||
# Strict matches structurally then fails security -> raises.
|
||||
with pytest.raises(ResourceSecurityError) as exc:
|
||||
strict.matches(uri)
|
||||
assert exc.value.param == "name"
|
||||
# If this raised, the resource manager never reaches the lax
|
||||
# template. Verify the lax template WOULD have accepted it.
|
||||
assert lax.matches(uri) == {"path": "../secrets"}
|
||||
|
||||
|
||||
def test_matches_explode_checks_each_segment():
|
||||
t = _make("api{/parts*}")
|
||||
assert t.matches("api/a/b/c") == {"parts": ["a", "b", "c"]}
|
||||
# Any segment with traversal rejects the whole match
|
||||
with pytest.raises(ResourceSecurityError):
|
||||
t.matches("api/a/../c")
|
||||
|
||||
|
||||
def test_matches_encoded_backslash_caught_by_traversal_check():
|
||||
# %5C decodes to '\\'. The traversal check normalizes '\\' to '/'
|
||||
# and catches the '..' components.
|
||||
t = _make("file://docs/{name}")
|
||||
with pytest.raises(ResourceSecurityError):
|
||||
t.matches("file://docs/..%5C..%5Csecret")
|
||||
|
||||
|
||||
def test_matches_encoded_dots_caught_by_traversal_check():
|
||||
# %2E%2E decodes to '..' which the traversal check rejects.
|
||||
t = _make("file://docs/{name}")
|
||||
with pytest.raises(ResourceSecurityError):
|
||||
t.matches("file://docs/%2E%2E")
|
||||
|
||||
|
||||
def test_matches_mixed_encoded_and_literal_slash():
|
||||
# The literal '/' stops the simple-var regex, so the URI doesn't
|
||||
# match the template at all.
|
||||
t = _make("file://docs/{name}")
|
||||
assert t.matches("file://docs/..%2F../etc") is None
|
||||
|
||||
|
||||
def test_matches_encoded_slash_without_traversal_allowed():
|
||||
# %2F decoding to '/' is fine when there's no traversal involved.
|
||||
# UriTemplate accepts it; ResourceSecurity only blocks '..' and
|
||||
# absolute paths. Handlers that need single-segment should use
|
||||
# safe_join or validate explicitly.
|
||||
t = _make("file://docs/{name}")
|
||||
assert t.matches("file://docs/sub%2Ffile.txt") == {"name": "sub/file.txt"}
|
||||
|
||||
|
||||
def test_matches_escapes_template_literals():
|
||||
# Regression: old impl treated . as regex wildcard
|
||||
t = _make("data://v1.0/{id}")
|
||||
assert t.matches("data://v1.0/42") == {"id": "42"}
|
||||
assert t.matches("data://v1X0/42") is None
|
||||
|
||||
|
||||
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},
|
||||
Context(),
|
||||
)
|
||||
|
||||
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(ResourceError, match="Error creating resource from template"):
|
||||
await template.create_resource("fail://test", {"x": "test"}, Context())
|
||||
|
||||
@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"},
|
||||
Context(),
|
||||
)
|
||||
|
||||
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"},
|
||||
Context(),
|
||||
)
|
||||
|
||||
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},
|
||||
Context(),
|
||||
)
|
||||
|
||||
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"},
|
||||
Context(),
|
||||
)
|
||||
|
||||
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"}, Context())
|
||||
assert not isinstance(resource, InputRequiredResult)
|
||||
|
||||
# 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"}, Context())
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sync_fn_runs_in_worker_thread():
|
||||
"""Sync template functions must run in a worker thread, not the event loop."""
|
||||
|
||||
main_thread = threading.get_ident()
|
||||
fn_thread: list[int] = []
|
||||
|
||||
def blocking_fn(name: str) -> str:
|
||||
fn_thread.append(threading.get_ident())
|
||||
return f"hello {name}"
|
||||
|
||||
template = ResourceTemplate.from_function(fn=blocking_fn, uri_template="test://{name}")
|
||||
resource = await template.create_resource("test://world", {"name": "world"}, Context())
|
||||
|
||||
assert isinstance(resource, FunctionResource)
|
||||
assert await resource.read() == "hello world"
|
||||
assert fn_thread[0] != main_thread
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_resource_passes_input_required_result_through_unchanged():
|
||||
"""create_resource returns the InputRequiredResult the template function returned
|
||||
instead of wrapping it in a FunctionResource (SEP-2322 multi-round-trip pass-through)."""
|
||||
sentinel = InputRequiredResult(
|
||||
input_requests={
|
||||
"who": ElicitRequest(
|
||||
params=ElicitRequestFormParams(
|
||||
message="Who is this for?",
|
||||
requested_schema={
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
"required": ["name"],
|
||||
},
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
def ask(topic: str) -> InputRequiredResult:
|
||||
return sentinel
|
||||
|
||||
template = ResourceTemplate.from_function(fn=ask, uri_template="ask://{topic}")
|
||||
result = await template.create_resource("ask://databases", {"topic": "databases"}, Context())
|
||||
assert result is sentinel
|
||||
@@ -0,0 +1,240 @@
|
||||
import pytest
|
||||
from mcp_types import Annotations
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from mcp.server.mcpserver.resources import FunctionResource, Resource
|
||||
|
||||
|
||||
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"
|
||||
|
||||
# RFC 2045 quoted parameter value (gh-1756)
|
||||
resource = FunctionResource(
|
||||
uri="resource://test",
|
||||
fn=dummy_func,
|
||||
mime_type='text/plain; charset="utf-8"',
|
||||
)
|
||||
assert resource.mime_type == 'text/plain; charset="utf-8"'
|
||||
|
||||
@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,0 +1,132 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from mcp_types import InputRequiredResult
|
||||
|
||||
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")
|
||||
assert not isinstance(res_iter, InputRequiredResult)
|
||||
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")
|
||||
assert not isinstance(res_iter, InputRequiredResult)
|
||||
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")
|
||||
assert not isinstance(res_iter, InputRequiredResult)
|
||||
res_list = list(res_iter)
|
||||
assert len(res_list) == 1
|
||||
res = res_list[0]
|
||||
assert res.content == "File not found"
|
||||
@@ -0,0 +1,405 @@
|
||||
"""Test the elicitation feature over the in-memory client transport."""
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
import mcp_types as types
|
||||
import pytest
|
||||
from mcp_types import ElicitRequestParams, ElicitResult, TextContent
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from mcp import Client
|
||||
from mcp.client import ClientRequestContext
|
||||
from mcp.client.session import ElicitationFnT
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
|
||||
|
||||
# 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) -> 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, mode="legacy", 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_elicitation_accept_returns_the_users_answer_to_the_tool():
|
||||
"""An accepted elicitation delivers the user's content back to the requesting tool."""
|
||||
mcp = MCPServer(name="ElicitationServer")
|
||||
create_ask_user_tool(mcp)
|
||||
|
||||
# Create a custom handler for elicitation requests
|
||||
async def elicitation_callback(context: ClientRequestContext, 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_elicitation_decline_reaches_the_tool_without_content():
|
||||
"""A declined elicitation reports the decline to the tool, with no content attached."""
|
||||
mcp = MCPServer(name="ElicitationDeclineServer")
|
||||
create_ask_user_tool(mcp)
|
||||
|
||||
async def elicitation_callback(context: ClientRequestContext, 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) -> 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: ClientRequestContext, params: ElicitRequestParams): # pragma: no cover
|
||||
return ElicitResult(action="accept", content={})
|
||||
|
||||
async with Client(mcp, mode="legacy", 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) -> 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: ClientRequestContext, params: ElicitRequestParams):
|
||||
assert isinstance(params, types.ElicitRequestFormParams)
|
||||
# Optional fields render as the bare primitive (no anyOf), absent from `required`.
|
||||
assert params.requested_schema["properties"]["optional_age"] == {
|
||||
"type": "integer",
|
||||
"title": "Optional Age",
|
||||
"description": "Your age (optional)",
|
||||
}
|
||||
assert params.requested_schema["required"] == ["required_name"]
|
||||
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) -> 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: ClientRequestContext, 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"],
|
||||
)
|
||||
|
||||
# Bare `list[str]` renders without enum items and so is not a spec MultiSelectEnumSchema.
|
||||
class BareListSchema(BaseModel):
|
||||
name: str = Field(description="Name")
|
||||
tags: list[str] = Field(description="Tags")
|
||||
|
||||
def make_reject_tool(tool_name: str, schema_cls: type[BaseModel]) -> None:
|
||||
@mcp.tool(name=tool_name, description="Tool with a rejected field")
|
||||
async def _tool(ctx: Context) -> str:
|
||||
try:
|
||||
await ctx.elicit(message="Provide value", schema=schema_cls)
|
||||
except TypeError as e:
|
||||
return f"Validation failed: {str(e)}"
|
||||
raise NotImplementedError
|
||||
|
||||
make_reject_tool("bare_list_tool", BareListSchema)
|
||||
await call_tool_and_assert(
|
||||
mcp, elicitation_callback, "bare_list_tool", {}, text_contains=["Validation failed:", "tags"]
|
||||
)
|
||||
|
||||
# A union of two primitives renders as `anyOf`, outside `PrimitiveSchemaDefinition`.
|
||||
class MultiPrimitiveSchema(BaseModel):
|
||||
value: int | str = Field(description="Value")
|
||||
|
||||
make_reject_tool("multi_primitive_tool", MultiPrimitiveSchema)
|
||||
await call_tool_and_assert(
|
||||
mcp, elicitation_callback, "multi_primitive_tool", {}, text_contains=["Validation failed:", "value"]
|
||||
)
|
||||
|
||||
|
||||
@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) -> 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: ClientRequestContext, 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: ClientRequestContext, 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) -> 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 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) -> 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
|
||||
|
||||
# Test multi-select with titles using items.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) -> 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)}"
|
||||
raise NotImplementedError
|
||||
|
||||
async def enum_callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
if "colors" in params.message:
|
||||
return ElicitResult(action="accept", content={"user_name": "Bob", "favorite_colors": ["red", "green"]})
|
||||
if "legacy" in params.message:
|
||||
return ElicitResult(action="accept", content={"user_name": "Charlie", "color": "green"})
|
||||
return ElicitResult(action="accept", content={"user_name": "Alice", "favorite_color": "blue"})
|
||||
|
||||
# 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")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_elicitation_literal_field_renders_as_a_spec_enum_schema():
|
||||
"""`Literal[...]` and `list[Literal[...]]` render as the spec's enum schemas and pass the gate."""
|
||||
mcp = MCPServer(name="LiteralServer")
|
||||
|
||||
class LiteralSchema(BaseModel):
|
||||
size: Literal["s", "m", "l"] = Field(description="Size")
|
||||
extras: list[Literal["a", "b"]] = Field(description="Extras")
|
||||
|
||||
@mcp.tool(description="Literal selection")
|
||||
async def pick(ctx: Context) -> str:
|
||||
result = await ctx.elicit(message="Pick", schema=LiteralSchema)
|
||||
if result.action == "accept" and result.data:
|
||||
return f"{result.data.size}:{','.join(result.data.extras)}"
|
||||
raise NotImplementedError
|
||||
|
||||
async def callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
assert isinstance(params, types.ElicitRequestFormParams)
|
||||
assert params.requested_schema["properties"]["size"]["enum"] == ["s", "m", "l"]
|
||||
assert params.requested_schema["properties"]["extras"]["items"]["enum"] == ["a", "b"]
|
||||
return ElicitResult(action="accept", content={"size": "m", "extras": ["a"]})
|
||||
|
||||
await call_tool_and_assert(mcp, callback, "pick", {}, "m:a")
|
||||
@@ -0,0 +1,441 @@
|
||||
"""Tests for the core SEP-2133 extension API (`Extension`, `MCPServer` wiring).
|
||||
|
||||
These exercise the closed set of extension contribution kinds - tools,
|
||||
resources, request methods, and the single `tools/call` interceptor - through
|
||||
the highest-level public surface (in-memory `Client`), plus the
|
||||
`compose_tool_call_interceptor` helper directly.
|
||||
"""
|
||||
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
import mcp_types as types
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import (
|
||||
METHOD_NOT_FOUND,
|
||||
MISSING_REQUIRED_CLIENT_CAPABILITY,
|
||||
CallToolResult,
|
||||
TextContent,
|
||||
)
|
||||
|
||||
from mcp.client import advertise
|
||||
from mcp.client.client import Client
|
||||
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
|
||||
from mcp.server.extension import (
|
||||
Extension,
|
||||
MethodBinding,
|
||||
ResourceBinding,
|
||||
ToolBinding,
|
||||
compose_tool_call_interceptor,
|
||||
)
|
||||
from mcp.server.mcpserver import Context, MCPServer, require_client_extension
|
||||
from mcp.server.mcpserver.resources import TextResource
|
||||
from mcp.shared.exceptions import MCPError
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
_TOOL_META: dict[str, Any] = {"com.example/marker": {"v": 1}}
|
||||
|
||||
|
||||
class _AdditiveExt(Extension):
|
||||
"""Override `tools()`/`resources()` only - a purely additive extension."""
|
||||
|
||||
identifier = "com.example/additive"
|
||||
|
||||
def tools(self):
|
||||
def ping() -> str:
|
||||
"""Reply with pong."""
|
||||
return "pong"
|
||||
|
||||
return [ToolBinding(fn=ping, meta=_TOOL_META)]
|
||||
|
||||
def resources(self):
|
||||
return [ResourceBinding(resource=TextResource(uri="ext://greeting", name="greeting", text="hello"))]
|
||||
|
||||
|
||||
class _SettingsExt(Extension):
|
||||
"""Override `settings()` so the extension advertises a non-empty settings map."""
|
||||
|
||||
identifier = "com.example/settings"
|
||||
|
||||
def settings(self) -> dict[str, Any]:
|
||||
return {"feature": {"enabled": True}}
|
||||
|
||||
|
||||
class _PingParams(types.RequestParams):
|
||||
pass
|
||||
|
||||
|
||||
class _PingResult(types.Result):
|
||||
pong: bool
|
||||
|
||||
|
||||
class _PingRequest(types.Request[_PingParams, Literal["com.example/ping"]]):
|
||||
method: Literal["com.example/ping"] = "com.example/ping"
|
||||
params: _PingParams
|
||||
|
||||
|
||||
async def _pong_handler(ctx: ServerRequestContext[Any, Any], params: _PingParams) -> _PingResult:
|
||||
"""The shared `com.example/ping` handler (dispatched by the reachability test)."""
|
||||
return _PingResult(pong=True)
|
||||
|
||||
|
||||
class _MethodExt(Extension):
|
||||
"""Override `methods()` to serve a new vendor request verb."""
|
||||
|
||||
identifier = "com.example/method"
|
||||
|
||||
def methods(self) -> list[MethodBinding]:
|
||||
return [MethodBinding("com.example/ping", _PingParams, _pong_handler)]
|
||||
|
||||
|
||||
class _ReplacingExt(Extension):
|
||||
"""Override `intercept_tool_call()` to short-circuit with a fixed result."""
|
||||
|
||||
identifier = "com.example/replacing"
|
||||
|
||||
async def intercept_tool_call(
|
||||
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
|
||||
) -> HandlerResult:
|
||||
return CallToolResult(content=[TextContent(type="text", text="intercepted")])
|
||||
|
||||
|
||||
class _PassThroughExt(Extension):
|
||||
"""Override `intercept_tool_call()` but always delegate to `call_next` unchanged."""
|
||||
|
||||
identifier = "com.example/passthrough"
|
||||
|
||||
async def intercept_tool_call(
|
||||
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
|
||||
) -> HandlerResult:
|
||||
return await call_next(ctx)
|
||||
|
||||
|
||||
class _DefaultExt(Extension):
|
||||
"""Override nothing - relies on the base `intercept_tool_call` default (pass through)."""
|
||||
|
||||
identifier = "com.example/default"
|
||||
|
||||
|
||||
class _RecordingExt(Extension):
|
||||
"""Override `intercept_tool_call()` to record `(identifier, tool_name)` then pass through."""
|
||||
|
||||
def __init__(self, identifier: str, log: list[tuple[str, str]]) -> None:
|
||||
self.identifier = identifier
|
||||
self._log = log
|
||||
|
||||
async def intercept_tool_call(
|
||||
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
|
||||
) -> HandlerResult:
|
||||
self._log.append((self.identifier, params.name))
|
||||
return await call_next(ctx)
|
||||
|
||||
|
||||
def _echo(value: str) -> str:
|
||||
"""Echo the input value (shared tool body across interceptor tests)."""
|
||||
return value
|
||||
|
||||
|
||||
async def test_additive_extension_registers_its_tool_and_resource() -> None:
|
||||
"""SDK-defined: an `Extension` overriding `tools()`/`resources()` surfaces both
|
||||
through `MCPServer`'s normal `list_tools`/`list_resources`, and the tool's
|
||||
`_meta` round-trips equal to the exact dict the binding carried (identity can't
|
||||
hold - the value is JSON-serialized over the transport)."""
|
||||
server = MCPServer("test", extensions=[_AdditiveExt()])
|
||||
|
||||
async with Client(server) as client:
|
||||
tools = await client.list_tools()
|
||||
resources = await client.list_resources()
|
||||
called = await client.call_tool("ping", {})
|
||||
|
||||
assert [t.name for t in tools.tools] == ["ping"]
|
||||
assert tools.tools[0].meta == _TOOL_META
|
||||
assert called == snapshot(CallToolResult(content=[TextContent(text="pong")], structured_content={"result": "pong"}))
|
||||
assert resources == snapshot(
|
||||
types.ListResourcesResult(
|
||||
resources=[types.Resource(name="greeting", uri="ext://greeting", mime_type="text/plain")]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def test_extension_settings_advertised_under_server_capabilities() -> None:
|
||||
"""SDK-defined: `settings()` rides `server/discover` and lands under
|
||||
`server_capabilities.extensions[identifier]` on the modern (`auto`) path."""
|
||||
server = MCPServer("test", extensions=[_SettingsExt()])
|
||||
|
||||
async with Client(server, mode="auto") as client:
|
||||
extensions = client.server_capabilities.extensions
|
||||
|
||||
assert extensions == snapshot({"com.example/settings": {"feature": {"enabled": True}}})
|
||||
|
||||
|
||||
async def test_extension_settings_dropped_on_legacy_handshake() -> None:
|
||||
"""Pinned gap: the 2025 `ServerCapabilities` wire schema has no `extensions`
|
||||
field, so a legacy `initialize` handshake drops the advertised extension even
|
||||
though the modern `auto` path carries it."""
|
||||
server = MCPServer("test", extensions=[_SettingsExt()])
|
||||
|
||||
async with Client(server, mode="legacy") as client:
|
||||
assert client.server_capabilities.extensions is None
|
||||
|
||||
|
||||
def test_duplicate_extension_identifier_raises() -> None:
|
||||
"""SDK-defined: registering two extensions with the same `identifier` is a
|
||||
construction error."""
|
||||
with pytest.raises(ValueError):
|
||||
MCPServer("test", extensions=[_SettingsExt(), _SettingsExt()])
|
||||
|
||||
|
||||
async def test_extension_method_reachable_via_session_send_request() -> None:
|
||||
"""SDK-defined: an `Extension` overriding `methods()` wires a new request verb
|
||||
onto the low-level server, reachable through `client.session.send_request`."""
|
||||
server = MCPServer("test", extensions=[_MethodExt()])
|
||||
|
||||
async with Client(server) as client:
|
||||
request = _PingRequest(params=_PingParams())
|
||||
result = await client.session.send_request(request, _PingResult)
|
||||
|
||||
assert result == snapshot(_PingResult(pong=True))
|
||||
|
||||
|
||||
async def test_pass_through_interceptor_leaves_tool_result_unchanged() -> None:
|
||||
"""SDK-defined: an extension whose `intercept_tool_call` delegates to
|
||||
`call_next` does not alter the underlying tool's `CallToolResult`."""
|
||||
server = MCPServer("test", extensions=[_PassThroughExt()])
|
||||
server.tool(name="echo")(_echo)
|
||||
|
||||
async with Client(server) as client:
|
||||
result = await client.call_tool("echo", {"value": "hi"})
|
||||
|
||||
assert result == snapshot(CallToolResult(content=[TextContent(text="hi")], structured_content={"result": "hi"}))
|
||||
|
||||
|
||||
async def test_short_circuiting_interceptor_replaces_tool_result() -> None:
|
||||
"""SDK-defined: an extension that returns from `intercept_tool_call` without
|
||||
calling `call_next` replaces the tool's result wholesale (the tool never runs)."""
|
||||
server = MCPServer("test", extensions=[_ReplacingExt()])
|
||||
server.tool(name="echo", structured_output=False)(_echo)
|
||||
|
||||
async with Client(server) as client:
|
||||
result = await client.call_tool("echo", {"value": "hi"})
|
||||
|
||||
assert result == snapshot(CallToolResult(content=[TextContent(text="intercepted")]))
|
||||
|
||||
|
||||
def test_plain_extension_installs_no_tool_call_interceptor() -> None:
|
||||
"""SDK-defined: an extension that does not override `intercept_tool_call` adds no
|
||||
middleware - the composed interceptor exists only when at least one extension
|
||||
overrides it."""
|
||||
baseline = len(MCPServer("test")._lowlevel_server.middleware)
|
||||
server = MCPServer("test", extensions=[_AdditiveExt()])
|
||||
|
||||
assert len(server._lowlevel_server.middleware) == baseline
|
||||
|
||||
|
||||
def test_overriding_extension_installs_one_tool_call_interceptor() -> None:
|
||||
"""SDK-defined: an extension that overrides `intercept_tool_call` composes exactly
|
||||
one additional `tools/call` middleware."""
|
||||
baseline = len(MCPServer("test")._lowlevel_server.middleware)
|
||||
server = MCPServer("test", extensions=[_ReplacingExt()])
|
||||
|
||||
assert len(server._lowlevel_server.middleware) == baseline + 1
|
||||
|
||||
|
||||
async def test_default_interceptor_passes_through_alongside_an_overriding_one() -> None:
|
||||
"""SDK-defined: an extension that does not override `intercept_tool_call` runs the
|
||||
base-class default (pass through) when another extension forces the composed
|
||||
middleware to exist, leaving the tool result untouched."""
|
||||
server = MCPServer("test", extensions=[_DefaultExt(), _PassThroughExt()])
|
||||
server.tool(name="echo")(_echo)
|
||||
|
||||
async with Client(server) as client:
|
||||
result = await client.call_tool("echo", {"value": "hi"})
|
||||
|
||||
assert result == snapshot(CallToolResult(content=[TextContent(text="hi")], structured_content={"result": "hi"}))
|
||||
|
||||
|
||||
async def test_interceptors_run_in_registration_order_with_threaded_params() -> None:
|
||||
"""SDK-defined: `compose_tool_call_interceptor` nests extensions first-outermost, so
|
||||
two passing-through interceptors record in registration order, each seeing the
|
||||
validated `tools/call` params (the real tool name)."""
|
||||
log: list[tuple[str, str]] = []
|
||||
server = MCPServer(
|
||||
"test",
|
||||
extensions=[_RecordingExt("com.example/first", log), _RecordingExt("com.example/second", log)],
|
||||
)
|
||||
server.tool(name="echo")(_echo)
|
||||
|
||||
async with Client(server) as client:
|
||||
await client.call_tool("echo", {"value": "hi"})
|
||||
|
||||
assert log == [("com.example/first", "echo"), ("com.example/second", "echo")]
|
||||
|
||||
|
||||
async def test_compose_tool_call_interceptor_passes_through_non_tools_call() -> None:
|
||||
"""SDK-defined: the composed middleware is a no-op for any method other than
|
||||
`tools/call` - it forwards to `call_next` without touching the interceptors."""
|
||||
sentinel = types.EmptyResult()
|
||||
|
||||
async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult:
|
||||
return sentinel
|
||||
|
||||
middleware = compose_tool_call_interceptor([_ReplacingExt()])
|
||||
ctx = ServerRequestContext(
|
||||
session=cast("Any", None),
|
||||
lifespan_context={},
|
||||
protocol_version="2026-07-28",
|
||||
method="tasks/get",
|
||||
params={"taskId": "t-1"},
|
||||
)
|
||||
|
||||
result = await middleware(ctx, call_next)
|
||||
|
||||
assert result is sentinel
|
||||
|
||||
|
||||
def test_extension_subclass_without_prefixed_identifier_is_rejected_at_definition() -> None:
|
||||
"""SDK-defined: SEP-2133 requires a `vendor-prefix/name` identifier, enforced when the
|
||||
subclass is defined (a bare name with no prefix is a TypeError)."""
|
||||
with pytest.raises(TypeError):
|
||||
type("_BadExt", (Extension,), {"identifier": "noprefix"})
|
||||
|
||||
|
||||
def test_extension_without_identifier_is_rejected_at_registration() -> None:
|
||||
"""SDK-defined: a subclass that never sets `identifier` (neither class-level nor in
|
||||
`__init__`) is rejected when the server applies it."""
|
||||
|
||||
class _NoIdExt(Extension):
|
||||
pass
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
MCPServer("test", extensions=[_NoIdExt()])
|
||||
|
||||
|
||||
class _VersionPinnedParams(types.RequestParams):
|
||||
pass
|
||||
|
||||
|
||||
class _VersionPinnedResult(types.Result):
|
||||
ok: bool
|
||||
|
||||
|
||||
class _VersionPinnedRequest(types.Request[_VersionPinnedParams, Literal["com.example/pinned"]]):
|
||||
method: Literal["com.example/pinned"] = "com.example/pinned"
|
||||
params: _VersionPinnedParams
|
||||
|
||||
|
||||
class _VersionPinnedExt(Extension):
|
||||
"""A method scoped to 2026-07-28 only via `MethodBinding.protocol_versions`."""
|
||||
|
||||
identifier = "com.example/pinned"
|
||||
|
||||
def methods(self):
|
||||
async def handler(ctx: ServerRequestContext[Any, Any], params: _VersionPinnedParams) -> _VersionPinnedResult:
|
||||
return _VersionPinnedResult(ok=True)
|
||||
|
||||
return [MethodBinding("com.example/pinned", _VersionPinnedParams, handler, frozenset({"2026-07-28"}))]
|
||||
|
||||
|
||||
async def test_version_pinned_method_is_served_at_an_allowed_version() -> None:
|
||||
"""SDK-defined: a `MethodBinding` with `protocol_versions` serves the method at a version
|
||||
in the set."""
|
||||
server = MCPServer("test", extensions=[_VersionPinnedExt()])
|
||||
|
||||
async with Client(server, mode="2026-07-28") as client:
|
||||
request = _VersionPinnedRequest(params=_VersionPinnedParams())
|
||||
result = await client.session.send_request(request, _VersionPinnedResult)
|
||||
|
||||
assert result == snapshot(_VersionPinnedResult(ok=True))
|
||||
|
||||
|
||||
async def test_version_pinned_method_is_method_not_found_at_a_disallowed_version() -> None:
|
||||
"""SDK-defined: the same method at a version outside `protocol_versions` is rejected with
|
||||
METHOD_NOT_FOUND, mirroring the spec's per-version boundary."""
|
||||
server = MCPServer("test", extensions=[_VersionPinnedExt()])
|
||||
|
||||
async with Client(server, mode="legacy") as client:
|
||||
request = _VersionPinnedRequest(params=_VersionPinnedParams())
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.session.send_request(request, _VersionPinnedResult)
|
||||
|
||||
assert exc_info.value.code == METHOD_NOT_FOUND
|
||||
assert exc_info.value.error.data == "com.example/pinned"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["tools/list", "completion/complete"])
|
||||
def test_method_binding_rejects_spec_methods(method: str) -> None:
|
||||
"""SDK-defined: extension methods are additive — binding a spec-defined request method
|
||||
would silently shadow (or be shadowed by) the server's own handler, so it is rejected
|
||||
when the binding is constructed."""
|
||||
with pytest.raises(ValueError):
|
||||
MethodBinding(method, _PingParams, _pong_handler)
|
||||
|
||||
|
||||
def test_method_binding_rejects_empty_protocol_versions() -> None:
|
||||
"""SDK-defined: an empty `protocol_versions` set would make the method unreachable at
|
||||
every version; `None` is the universal-version spelling."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
MethodBinding("com.example/dead", _PingParams, _pong_handler, frozenset())
|
||||
assert str(exc_info.value) == snapshot(
|
||||
"MethodBinding for 'com.example/dead' has an empty protocol_versions set, so it could "
|
||||
"never be served; use None to admit every version"
|
||||
)
|
||||
|
||||
|
||||
class _OtherMethodExt(Extension):
|
||||
"""A second extension binding the same verb as `_MethodExt`."""
|
||||
|
||||
identifier = "com.example/other-method"
|
||||
|
||||
def methods(self) -> list[MethodBinding]:
|
||||
return [MethodBinding("com.example/ping", _PingParams, _pong_handler)]
|
||||
|
||||
|
||||
def test_colliding_extension_methods_are_rejected_at_registration() -> None:
|
||||
"""SDK-defined: two extensions binding the same method would silently last-write-win;
|
||||
the collision is rejected when the second extension is applied."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
MCPServer("test", extensions=[_MethodExt(), _OtherMethodExt()])
|
||||
assert str(exc_info.value) == snapshot(
|
||||
"Extension 'com.example/other-method' binds method 'com.example/ping', which is already "
|
||||
"registered; extension methods are additive and cannot replace another handler"
|
||||
)
|
||||
|
||||
|
||||
_NEEDS_EXT = "com.example/needed"
|
||||
|
||||
|
||||
class _RequiresExt(Extension):
|
||||
"""A tool that requires the client to have declared `com.example/needed`."""
|
||||
|
||||
identifier = _NEEDS_EXT
|
||||
|
||||
def tools(self):
|
||||
def guarded(ctx: Context) -> str:
|
||||
require_client_extension(ctx.request_context, _NEEDS_EXT)
|
||||
return "ok"
|
||||
|
||||
return [ToolBinding(fn=guarded)]
|
||||
|
||||
|
||||
async def test_require_client_extension_passes_when_client_declared_it() -> None:
|
||||
"""SDK-defined: `require_client_extension` is a no-op when the client advertised the id."""
|
||||
server = MCPServer("test", extensions=[_RequiresExt()])
|
||||
|
||||
async with Client(server, extensions=[advertise(_NEEDS_EXT)]) as client:
|
||||
result = await client.call_tool("guarded", {})
|
||||
|
||||
assert result == snapshot(CallToolResult(content=[TextContent(text="ok")], structured_content={"result": "ok"}))
|
||||
|
||||
|
||||
async def test_require_client_extension_raises_minus_32021_when_client_did_not_declare_it() -> None:
|
||||
"""SDK-defined: `require_client_extension` raises the -32021 missing-required-capability
|
||||
error when the client did not advertise the id."""
|
||||
server = MCPServer("test", extensions=[_RequiresExt()])
|
||||
|
||||
async with Client(server) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.call_tool("guarded", {})
|
||||
|
||||
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
|
||||
assert exc_info.value.error.data == snapshot({"requiredCapabilities": {"extensions": {_NEEDS_EXT: {}}}})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,349 @@
|
||||
"""Integration tests for MCPServer server functionality.
|
||||
|
||||
These tests validate the proper functioning of MCPServer features using focused,
|
||||
single-feature example servers over an in-memory transport.
|
||||
"""
|
||||
# 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 pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import (
|
||||
ClientResult,
|
||||
CreateMessageRequestParams,
|
||||
CreateMessageResult,
|
||||
ElicitRequestParams,
|
||||
ElicitResult,
|
||||
GetPromptResult,
|
||||
LoggingMessageNotification,
|
||||
LoggingMessageNotificationParams,
|
||||
NotificationParams,
|
||||
ProgressNotification,
|
||||
ProgressNotificationParams,
|
||||
PromptReference,
|
||||
ReadResourceResult,
|
||||
ResourceListChangedNotification,
|
||||
ResourceTemplateReference,
|
||||
ServerNotification,
|
||||
ServerRequest,
|
||||
TextContent,
|
||||
TextResourceContents,
|
||||
ToolListChangedNotification,
|
||||
)
|
||||
|
||||
from examples.snippets.servers import (
|
||||
basic_prompt,
|
||||
basic_resource,
|
||||
basic_tool,
|
||||
completion,
|
||||
elicitation,
|
||||
mcpserver_quickstart,
|
||||
notifications,
|
||||
sampling,
|
||||
structured_output,
|
||||
tool_progress,
|
||||
)
|
||||
from mcp.client import Client, ClientRequestContext
|
||||
from mcp.shared.session import RequestResponder
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
async def sampling_callback(context: ClientRequestContext, 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: ClientRequestContext, 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")
|
||||
|
||||
|
||||
async def test_basic_tools() -> None:
|
||||
"""Test basic tool functionality."""
|
||||
async with Client(basic_tool.mcp) as client:
|
||||
assert client.server_capabilities.tools is not None
|
||||
|
||||
# Test sum tool
|
||||
tool_result = await client.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 client.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
|
||||
|
||||
|
||||
async def test_basic_resources() -> None:
|
||||
"""Test basic resource functionality."""
|
||||
async with Client(basic_resource.mcp) as client:
|
||||
assert client.server_capabilities.resources is not None
|
||||
|
||||
# Test document resource
|
||||
doc_content = await client.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 client.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"
|
||||
|
||||
|
||||
async def test_basic_prompts() -> None:
|
||||
"""Test basic prompt functionality."""
|
||||
async with Client(basic_prompt.mcp) as client:
|
||||
assert client.server_capabilities.prompts is not None
|
||||
|
||||
# Test review_code prompt
|
||||
prompts = await client.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 client.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 client.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
|
||||
|
||||
|
||||
async def test_tool_progress() -> None:
|
||||
"""Test tool progress reporting."""
|
||||
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
|
||||
|
||||
async with Client(tool_progress.mcp, message_handler=message_handler, mode="legacy") as client:
|
||||
# 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 client.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
|
||||
|
||||
|
||||
async def test_sampling() -> None:
|
||||
"""Test sampling (LLM interaction) functionality."""
|
||||
async with Client(sampling.mcp, sampling_callback=sampling_callback, mode="legacy") as client:
|
||||
assert client.server_capabilities.tools is not None
|
||||
|
||||
# Test sampling tool
|
||||
sampling_result = await client.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
|
||||
|
||||
|
||||
async def test_elicitation() -> None:
|
||||
"""Test elicitation (user interaction) functionality."""
|
||||
async with Client(elicitation.mcp, elicitation_callback=elicitation_callback, mode="legacy") as client:
|
||||
# Test booking with unavailable date (triggers elicitation)
|
||||
booking_result = await client.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 client.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
|
||||
|
||||
|
||||
async def test_notifications() -> None:
|
||||
"""Test notifications and logging functionality."""
|
||||
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
|
||||
|
||||
async with Client(notifications.mcp, message_handler=message_handler, mode="legacy") as client:
|
||||
# Call tool that generates notifications
|
||||
tool_result = await client.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
|
||||
|
||||
|
||||
async def test_completion() -> None:
|
||||
"""Test completion (autocomplete) functionality."""
|
||||
async with Client(completion.mcp) as client:
|
||||
assert client.server_capabilities.resources is not None
|
||||
assert client.server_capabilities.prompts is not None
|
||||
|
||||
# Test resource completion
|
||||
completion_result = await client.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 client.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)
|
||||
|
||||
|
||||
async def test_mcpserver_quickstart() -> None:
|
||||
"""Test MCPServer quickstart example."""
|
||||
async with Client(mcpserver_quickstart.mcp) as client:
|
||||
# Test add tool
|
||||
tool_result = await client.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 client.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!"
|
||||
|
||||
|
||||
async def test_structured_output() -> None:
|
||||
"""Test structured output functionality."""
|
||||
async with Client(structured_output.mcp) as client:
|
||||
# Test get_weather tool
|
||||
weather_result = await client.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
|
||||
@@ -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"
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,230 @@
|
||||
"""Integration tests for title field functionality."""
|
||||
|
||||
import pytest
|
||||
from mcp_types import Prompt, Resource, ResourceTemplate, Tool, ToolAnnotations
|
||||
|
||||
from mcp import Client
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from mcp.server.mcpserver.resources import FunctionResource
|
||||
from mcp.shared.metadata_utils import get_display_name
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_server_name_title_description_version():
|
||||
"""Test that server title and description are set and retrievable correctly."""
|
||||
mcp = MCPServer(
|
||||
name="TestServer",
|
||||
title="Test Server Title",
|
||||
description="This is a test server description.",
|
||||
version="1.0",
|
||||
)
|
||||
|
||||
assert mcp.title == "Test Server Title"
|
||||
assert mcp.description == "This is a test server description."
|
||||
assert mcp.version == "1.0"
|
||||
|
||||
# Start server and connect client
|
||||
async with Client(mcp) as client:
|
||||
assert client.server_info.name == "TestServer"
|
||||
assert client.server_info.title == "Test Server Title"
|
||||
assert client.server_info.description == "This is a test server description."
|
||||
assert client.server_info.version == "1.0"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_title_precedence():
|
||||
"""Test that tool title precedence works correctly: title > annotations.title > name."""
|
||||
# Create server with various tool configurations
|
||||
mcp = MCPServer(name="TitleTestServer")
|
||||
|
||||
# Tool with only name
|
||||
@mcp.tool(description="Basic tool")
|
||||
def basic_tool(message: str) -> str: # pragma: no cover
|
||||
return message
|
||||
|
||||
# Tool with title
|
||||
@mcp.tool(description="Tool with title", title="User-Friendly Tool")
|
||||
def tool_with_title(message: str) -> str: # pragma: no cover
|
||||
return message
|
||||
|
||||
# Tool with annotations.title (when title is not supported on decorator)
|
||||
# We'll need to add this manually after registration
|
||||
@mcp.tool(description="Tool with annotations")
|
||||
def tool_with_annotations(message: str) -> str: # pragma: no cover
|
||||
return message
|
||||
|
||||
# Tool with both title and annotations.title
|
||||
@mcp.tool(description="Tool with both", title="Primary Title")
|
||||
def tool_with_both(message: str) -> str: # pragma: no cover
|
||||
return message
|
||||
|
||||
# Start server and connect client
|
||||
async with Client(mcp) as client:
|
||||
# List tools
|
||||
tools_result = await client.list_tools()
|
||||
tools = {tool.name: tool for tool in tools_result.tools}
|
||||
|
||||
# Verify basic tool uses name
|
||||
assert "basic_tool" in tools
|
||||
basic = tools["basic_tool"]
|
||||
# Since we haven't implemented get_display_name yet, we'll check the raw fields
|
||||
assert basic.title is None
|
||||
assert basic.name == "basic_tool"
|
||||
|
||||
# Verify tool with title
|
||||
assert "tool_with_title" in tools
|
||||
titled = tools["tool_with_title"]
|
||||
assert titled.title == "User-Friendly Tool"
|
||||
|
||||
# For now, we'll skip the annotations.title test as it requires modifying
|
||||
# the tool after registration, which we'll implement later
|
||||
|
||||
# Verify tool with both uses title over annotations.title
|
||||
assert "tool_with_both" in tools
|
||||
both = tools["tool_with_both"]
|
||||
assert both.title == "Primary Title"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_prompt_title():
|
||||
"""Test that prompt titles work correctly."""
|
||||
mcp = MCPServer(name="PromptTitleServer")
|
||||
|
||||
# Prompt with only name
|
||||
@mcp.prompt(description="Basic prompt")
|
||||
def basic_prompt(topic: str) -> str: # pragma: no cover
|
||||
return f"Tell me about {topic}"
|
||||
|
||||
# Prompt with title
|
||||
@mcp.prompt(description="Titled prompt", title="Ask About Topic")
|
||||
def titled_prompt(topic: str) -> str: # pragma: no cover
|
||||
return f"Tell me about {topic}"
|
||||
|
||||
# Start server and connect client
|
||||
async with Client(mcp) as client:
|
||||
# List prompts
|
||||
prompts_result = await client.list_prompts()
|
||||
prompts = {prompt.name: prompt for prompt in prompts_result.prompts}
|
||||
|
||||
# Verify basic prompt uses name
|
||||
assert "basic_prompt" in prompts
|
||||
basic = prompts["basic_prompt"]
|
||||
assert basic.title is None
|
||||
assert basic.name == "basic_prompt"
|
||||
|
||||
# Verify prompt with title
|
||||
assert "titled_prompt" in prompts
|
||||
titled = prompts["titled_prompt"]
|
||||
assert titled.title == "Ask About Topic"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_resource_title():
|
||||
"""Test that resource titles work correctly."""
|
||||
mcp = MCPServer(name="ResourceTitleServer")
|
||||
|
||||
# Static resource without title
|
||||
def get_basic_data() -> str: # pragma: no cover
|
||||
return "Basic data"
|
||||
|
||||
basic_resource = FunctionResource(
|
||||
uri="resource://basic",
|
||||
name="basic_resource",
|
||||
description="Basic resource",
|
||||
fn=get_basic_data,
|
||||
)
|
||||
mcp.add_resource(basic_resource)
|
||||
|
||||
# Static resource with title
|
||||
def get_titled_data() -> str: # pragma: no cover
|
||||
return "Titled data"
|
||||
|
||||
titled_resource = FunctionResource(
|
||||
uri="resource://titled",
|
||||
name="titled_resource",
|
||||
title="User-Friendly Resource",
|
||||
description="Resource with title",
|
||||
fn=get_titled_data,
|
||||
)
|
||||
mcp.add_resource(titled_resource)
|
||||
|
||||
# Dynamic resource without title
|
||||
@mcp.resource("resource://dynamic/{id}")
|
||||
def dynamic_resource(id: str) -> str: # pragma: no cover
|
||||
return f"Data for {id}"
|
||||
|
||||
# Dynamic resource with title (when supported)
|
||||
@mcp.resource("resource://titled-dynamic/{id}", title="Dynamic Data")
|
||||
def titled_dynamic_resource(id: str) -> str: # pragma: no cover
|
||||
return f"Data for {id}"
|
||||
|
||||
# Start server and connect client
|
||||
async with Client(mcp) as client:
|
||||
# List resources
|
||||
resources_result = await client.list_resources()
|
||||
resources = {str(res.uri): res for res in resources_result.resources}
|
||||
|
||||
# Verify basic resource uses name
|
||||
assert "resource://basic" in resources
|
||||
basic = resources["resource://basic"]
|
||||
assert basic.title is None
|
||||
assert basic.name == "basic_resource"
|
||||
|
||||
# Verify resource with title
|
||||
assert "resource://titled" in resources
|
||||
titled = resources["resource://titled"]
|
||||
assert titled.title == "User-Friendly Resource"
|
||||
|
||||
# List resource templates
|
||||
templates_result = await client.list_resource_templates()
|
||||
templates = {tpl.uri_template: tpl for tpl in templates_result.resource_templates}
|
||||
|
||||
# Verify dynamic resource template
|
||||
assert "resource://dynamic/{id}" in templates
|
||||
dynamic = templates["resource://dynamic/{id}"]
|
||||
assert dynamic.title is None
|
||||
assert dynamic.name == "dynamic_resource"
|
||||
|
||||
# Verify titled dynamic resource template (when supported)
|
||||
if "resource://titled-dynamic/{id}" in templates: # pragma: no branch
|
||||
titled_dynamic = templates["resource://titled-dynamic/{id}"]
|
||||
assert titled_dynamic.title == "Dynamic Data"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_display_name_utility():
|
||||
"""Test the get_display_name utility function."""
|
||||
|
||||
# Test tool precedence: title > annotations.title > name
|
||||
tool_name_only = Tool(name="test_tool", input_schema={})
|
||||
assert get_display_name(tool_name_only) == "test_tool"
|
||||
|
||||
tool_with_title = Tool(name="test_tool", title="Test Tool", input_schema={})
|
||||
assert get_display_name(tool_with_title) == "Test Tool"
|
||||
|
||||
tool_with_annotations = Tool(name="test_tool", input_schema={}, annotations=ToolAnnotations(title="Annotated Tool"))
|
||||
assert get_display_name(tool_with_annotations) == "Annotated Tool"
|
||||
|
||||
tool_with_both = Tool(
|
||||
name="test_tool", title="Primary Title", input_schema={}, annotations=ToolAnnotations(title="Secondary Title")
|
||||
)
|
||||
assert get_display_name(tool_with_both) == "Primary Title"
|
||||
|
||||
# Test other types: title > name
|
||||
resource = Resource(uri="file://test", name="test_res")
|
||||
assert get_display_name(resource) == "test_res"
|
||||
|
||||
resource_with_title = Resource(uri="file://test", name="test_res", title="Test Resource")
|
||||
assert get_display_name(resource_with_title) == "Test Resource"
|
||||
|
||||
prompt = Prompt(name="test_prompt")
|
||||
assert get_display_name(prompt) == "test_prompt"
|
||||
|
||||
prompt_with_title = Prompt(name="test_prompt", title="Test Prompt")
|
||||
assert get_display_name(prompt_with_title) == "Test Prompt"
|
||||
|
||||
template = ResourceTemplate(uri_template="file://{id}", name="test_template")
|
||||
assert get_display_name(template) == "test_template"
|
||||
|
||||
template_with_title = ResourceTemplate(uri_template="file://{id}", name="test_template", title="Test Template")
|
||||
assert get_display_name(template_with_title) == "Test Template"
|
||||
@@ -0,0 +1,906 @@
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, TypedDict
|
||||
|
||||
import pytest
|
||||
from mcp_types import CallToolResult, TextContent, ToolAnnotations
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mcp.server.context import LifespanContextT, RequestT
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from mcp.server.mcpserver.exceptions import ToolError
|
||||
from mcp.server.mcpserver.tools import Tool, ToolManager
|
||||
from mcp.server.mcpserver.utilities.func_metadata import ArgModelBase, FuncMetadata
|
||||
|
||||
|
||||
class TestAddTools:
|
||||
def test_basic_function(self):
|
||||
"""Test registering and running a basic function."""
|
||||
|
||||
def sum(a: int, b: int) -> int: # pragma: no cover
|
||||
"""Add two numbers."""
|
||||
return a + b
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(sum)
|
||||
|
||||
tool = manager.get_tool("sum")
|
||||
assert tool is not None
|
||||
assert tool.name == "sum"
|
||||
assert tool.description == "Add two numbers."
|
||||
assert tool.is_async is False
|
||||
assert tool.parameters["properties"]["a"]["type"] == "integer"
|
||||
assert tool.parameters["properties"]["b"]["type"] == "integer"
|
||||
|
||||
def test_init_with_tools(self, caplog: pytest.LogCaptureFixture):
|
||||
def sum(a: int, b: int) -> int: # pragma: no cover
|
||||
return a + b
|
||||
|
||||
class AddArguments(ArgModelBase):
|
||||
a: int
|
||||
b: int
|
||||
|
||||
fn_metadata = FuncMetadata(arg_model=AddArguments)
|
||||
|
||||
original_tool = Tool(
|
||||
name="sum",
|
||||
title="Add Tool",
|
||||
description="Add two numbers.",
|
||||
fn=sum,
|
||||
fn_metadata=fn_metadata,
|
||||
is_async=False,
|
||||
parameters=AddArguments.model_json_schema(),
|
||||
context_kwarg=None,
|
||||
annotations=None,
|
||||
)
|
||||
manager = ToolManager(tools=[original_tool])
|
||||
saved_tool = manager.get_tool("sum")
|
||||
assert saved_tool == original_tool
|
||||
|
||||
# warn on duplicate tools
|
||||
with caplog.at_level(logging.WARNING):
|
||||
manager = ToolManager(True, tools=[original_tool, original_tool])
|
||||
assert "Tool already exists: sum" in caplog.text
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_async_function(self):
|
||||
"""Test registering and running an async function."""
|
||||
|
||||
async def fetch_data(url: str) -> str: # pragma: no cover
|
||||
"""Fetch data from URL."""
|
||||
return f"Data from {url}"
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(fetch_data)
|
||||
|
||||
tool = manager.get_tool("fetch_data")
|
||||
assert tool is not None
|
||||
assert tool.name == "fetch_data"
|
||||
assert tool.description == "Fetch data from URL."
|
||||
assert tool.is_async is True
|
||||
assert tool.parameters["properties"]["url"]["type"] == "string"
|
||||
|
||||
def test_pydantic_model_function(self):
|
||||
"""Test registering a function that takes a Pydantic model."""
|
||||
|
||||
class UserInput(BaseModel):
|
||||
name: str
|
||||
age: int
|
||||
|
||||
def create_user(user: UserInput, flag: bool) -> dict[str, Any]: # pragma: no cover
|
||||
"""Create a new user."""
|
||||
return {"id": 1, **user.model_dump()}
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(create_user)
|
||||
|
||||
tool = manager.get_tool("create_user")
|
||||
assert tool is not None
|
||||
assert tool.name == "create_user"
|
||||
assert tool.description == "Create a new user."
|
||||
assert tool.is_async is False
|
||||
assert "name" in tool.parameters["$defs"]["UserInput"]["properties"]
|
||||
assert "age" in tool.parameters["$defs"]["UserInput"]["properties"]
|
||||
assert "flag" in tool.parameters["properties"]
|
||||
|
||||
def test_add_callable_object(self):
|
||||
"""Test registering a callable object."""
|
||||
|
||||
class MyTool:
|
||||
def __init__(self):
|
||||
self.__name__ = "MyTool"
|
||||
|
||||
def __call__(self, x: int) -> int: # pragma: no cover
|
||||
return x * 2
|
||||
|
||||
manager = ToolManager()
|
||||
tool = manager.add_tool(MyTool())
|
||||
assert tool.name == "MyTool"
|
||||
assert tool.is_async is False
|
||||
assert tool.parameters["properties"]["x"]["type"] == "integer"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_add_async_callable_object(self):
|
||||
"""Test registering an async callable object."""
|
||||
|
||||
class MyAsyncTool:
|
||||
def __init__(self):
|
||||
self.__name__ = "MyAsyncTool"
|
||||
|
||||
async def __call__(self, x: int) -> int: # pragma: no cover
|
||||
return x * 2
|
||||
|
||||
manager = ToolManager()
|
||||
tool = manager.add_tool(MyAsyncTool())
|
||||
assert tool.name == "MyAsyncTool"
|
||||
assert tool.is_async is True
|
||||
assert tool.parameters["properties"]["x"]["type"] == "integer"
|
||||
|
||||
def test_add_invalid_tool(self):
|
||||
manager = ToolManager()
|
||||
with pytest.raises(AttributeError):
|
||||
manager.add_tool(1) # type: ignore
|
||||
|
||||
def test_add_lambda(self):
|
||||
manager = ToolManager()
|
||||
tool = manager.add_tool(lambda x: x, name="my_tool") # type: ignore[reportUnknownLambdaType]
|
||||
assert tool.name == "my_tool"
|
||||
|
||||
def test_add_lambda_with_no_name(self):
|
||||
manager = ToolManager()
|
||||
with pytest.raises(ValueError, match="You must provide a name for lambda functions"):
|
||||
manager.add_tool(lambda x: x) # type: ignore[reportUnknownLambdaType]
|
||||
|
||||
def test_warn_on_duplicate_tools(self, caplog: pytest.LogCaptureFixture):
|
||||
"""Test warning on duplicate tools."""
|
||||
|
||||
def f(x: int) -> int: # pragma: no cover
|
||||
return x
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(f)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
manager.add_tool(f)
|
||||
assert "Tool already exists: f" in caplog.text
|
||||
|
||||
def test_disable_warn_on_duplicate_tools(self, caplog: pytest.LogCaptureFixture):
|
||||
"""Test disabling warning on duplicate tools."""
|
||||
|
||||
def f(x: int) -> int: # pragma: no cover
|
||||
return x
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(f)
|
||||
manager.warn_on_duplicate_tools = False
|
||||
with caplog.at_level(logging.WARNING):
|
||||
manager.add_tool(f)
|
||||
assert "Tool already exists: f" not in caplog.text
|
||||
|
||||
|
||||
class TestCallTools:
|
||||
@pytest.mark.anyio
|
||||
async def test_call_tool(self):
|
||||
def sum(a: int, b: int) -> int:
|
||||
"""Add two numbers."""
|
||||
return a + b
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(sum)
|
||||
result = await manager.call_tool("sum", {"a": 1, "b": 2}, Context())
|
||||
assert result == 3
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_call_async_tool(self):
|
||||
async def double(n: int) -> int:
|
||||
"""Double a number."""
|
||||
return n * 2
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(double)
|
||||
result = await manager.call_tool("double", {"n": 5}, Context())
|
||||
assert result == 10
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_call_object_tool(self):
|
||||
class MyTool:
|
||||
def __init__(self):
|
||||
self.__name__ = "MyTool"
|
||||
|
||||
def __call__(self, x: int) -> int:
|
||||
return x * 2
|
||||
|
||||
manager = ToolManager()
|
||||
tool = manager.add_tool(MyTool())
|
||||
result = await tool.run({"x": 5}, Context())
|
||||
assert result == 10
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_call_async_object_tool(self):
|
||||
class MyAsyncTool:
|
||||
def __init__(self):
|
||||
self.__name__ = "MyAsyncTool"
|
||||
|
||||
async def __call__(self, x: int) -> int:
|
||||
return x * 2
|
||||
|
||||
manager = ToolManager()
|
||||
tool = manager.add_tool(MyAsyncTool())
|
||||
result = await tool.run({"x": 5}, Context())
|
||||
assert result == 10
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_call_tool_with_default_args(self):
|
||||
def sum(a: int, b: int = 1) -> int:
|
||||
"""Add two numbers."""
|
||||
return a + b
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(sum)
|
||||
result = await manager.call_tool("sum", {"a": 1}, Context())
|
||||
assert result == 2
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_call_tool_with_missing_args(self):
|
||||
def sum(a: int, b: int) -> int: # pragma: no cover
|
||||
"""Add two numbers."""
|
||||
return a + b
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(sum)
|
||||
with pytest.raises(ToolError):
|
||||
await manager.call_tool("sum", {"a": 1}, Context())
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_call_unknown_tool(self):
|
||||
manager = ToolManager()
|
||||
with pytest.raises(ToolError):
|
||||
await manager.call_tool("unknown", {"a": 1}, Context())
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_call_tool_with_list_int_input(self):
|
||||
def sum_vals(vals: list[int]) -> int:
|
||||
return sum(vals)
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(sum_vals)
|
||||
# Try both with plain list and with JSON list
|
||||
result = await manager.call_tool("sum_vals", {"vals": "[1, 2, 3]"}, Context())
|
||||
assert result == 6
|
||||
result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]}, Context())
|
||||
assert result == 6
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_call_tool_with_list_str_or_str_input(self):
|
||||
def concat_strs(vals: list[str] | str) -> str:
|
||||
return vals if isinstance(vals, str) else "".join(vals)
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(concat_strs)
|
||||
# Try both with plain python object and with JSON list
|
||||
result = await manager.call_tool("concat_strs", {"vals": ["a", "b", "c"]}, Context())
|
||||
assert result == "abc"
|
||||
result = await manager.call_tool("concat_strs", {"vals": '["a", "b", "c"]'}, Context())
|
||||
assert result == "abc"
|
||||
result = await manager.call_tool("concat_strs", {"vals": "a"}, Context())
|
||||
assert result == "a"
|
||||
result = await manager.call_tool("concat_strs", {"vals": '"a"'}, Context())
|
||||
assert result == '"a"'
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_call_tool_with_complex_model(self):
|
||||
class MyShrimpTank(BaseModel):
|
||||
class Shrimp(BaseModel):
|
||||
name: str
|
||||
|
||||
shrimp: list[Shrimp]
|
||||
x: None
|
||||
|
||||
def name_shrimp(tank: MyShrimpTank) -> list[str]:
|
||||
return [x.name for x in tank.shrimp]
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(name_shrimp)
|
||||
result = await manager.call_tool(
|
||||
"name_shrimp",
|
||||
{"tank": {"x": None, "shrimp": [{"name": "rex"}, {"name": "gertrude"}]}},
|
||||
Context(),
|
||||
)
|
||||
assert result == ["rex", "gertrude"]
|
||||
result = await manager.call_tool(
|
||||
"name_shrimp",
|
||||
{"tank": '{"x": null, "shrimp": [{"name": "rex"}, {"name": "gertrude"}]}'},
|
||||
Context(),
|
||||
)
|
||||
assert result == ["rex", "gertrude"]
|
||||
|
||||
|
||||
class TestToolSchema:
|
||||
@pytest.mark.anyio
|
||||
async def test_context_arg_excluded_from_schema(self):
|
||||
def something(a: int, ctx: Context) -> int: # pragma: no cover
|
||||
return a
|
||||
|
||||
manager = ToolManager()
|
||||
tool = manager.add_tool(something)
|
||||
assert "ctx" not in json.dumps(tool.parameters)
|
||||
assert "Context" not in json.dumps(tool.parameters)
|
||||
assert "ctx" not in tool.fn_metadata.arg_model.model_fields
|
||||
|
||||
|
||||
class TestContextHandling:
|
||||
"""Test context handling in the tool manager."""
|
||||
|
||||
def test_context_parameter_detection(self):
|
||||
"""Test that context parameters are properly detected in
|
||||
Tool.from_function()."""
|
||||
|
||||
def tool_with_context(x: int, ctx: Context) -> str: # pragma: no cover
|
||||
return str(x)
|
||||
|
||||
manager = ToolManager()
|
||||
tool = manager.add_tool(tool_with_context)
|
||||
assert tool.context_kwarg == "ctx"
|
||||
|
||||
def tool_without_context(x: int) -> str: # pragma: no cover
|
||||
return str(x)
|
||||
|
||||
tool = manager.add_tool(tool_without_context)
|
||||
assert tool.context_kwarg is None
|
||||
|
||||
def tool_with_parametrized_context(x: int, ctx: Context[LifespanContextT, RequestT]) -> str: # pragma: no cover
|
||||
return str(x)
|
||||
|
||||
tool = manager.add_tool(tool_with_parametrized_context)
|
||||
assert tool.context_kwarg == "ctx"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_injection(self):
|
||||
"""Test that context is properly injected during tool execution."""
|
||||
|
||||
def tool_with_context(x: int, ctx: Context) -> str:
|
||||
assert isinstance(ctx, Context)
|
||||
return str(x)
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(tool_with_context)
|
||||
|
||||
result = await manager.call_tool("tool_with_context", {"x": 42}, context=Context())
|
||||
assert result == "42"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_injection_async(self):
|
||||
"""Test that context is properly injected in async tools."""
|
||||
|
||||
async def async_tool(x: int, ctx: Context) -> str:
|
||||
assert isinstance(ctx, Context)
|
||||
return str(x)
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(async_tool)
|
||||
|
||||
result = await manager.call_tool("async_tool", {"x": 42}, context=Context())
|
||||
assert result == "42"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_error_handling(self):
|
||||
"""Test error handling when context injection fails."""
|
||||
|
||||
def tool_with_context(x: int, ctx: Context) -> str:
|
||||
raise ValueError("Test error")
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(tool_with_context)
|
||||
|
||||
with pytest.raises(ToolError, match="Error executing tool tool_with_context"):
|
||||
await manager.call_tool("tool_with_context", {"x": 42}, context=Context())
|
||||
|
||||
|
||||
class TestToolAnnotations:
|
||||
def test_tool_annotations(self):
|
||||
"""Test that tool annotations are correctly added to tools."""
|
||||
|
||||
def read_data(path: str) -> str: # pragma: no cover
|
||||
"""Read data from a file."""
|
||||
return f"Data from {path}"
|
||||
|
||||
annotations = ToolAnnotations(
|
||||
title="File Reader",
|
||||
read_only_hint=True,
|
||||
open_world_hint=False,
|
||||
)
|
||||
|
||||
manager = ToolManager()
|
||||
tool = manager.add_tool(read_data, annotations=annotations)
|
||||
|
||||
assert tool.annotations is not None
|
||||
assert tool.annotations.title == "File Reader"
|
||||
assert tool.annotations.read_only_hint is True
|
||||
assert tool.annotations.open_world_hint is False
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_annotations_in_mcpserver(self):
|
||||
"""Test that tool annotations are included in MCPTool conversion."""
|
||||
|
||||
app = MCPServer()
|
||||
|
||||
@app.tool(annotations=ToolAnnotations(title="Echo Tool", read_only_hint=True))
|
||||
def echo(message: str) -> str: # pragma: no cover
|
||||
"""Echo a message back."""
|
||||
return message
|
||||
|
||||
tools = await app.list_tools()
|
||||
assert len(tools) == 1
|
||||
assert tools[0].annotations is not None
|
||||
assert tools[0].annotations.title == "Echo Tool"
|
||||
assert tools[0].annotations.read_only_hint is True
|
||||
|
||||
|
||||
class TestStructuredOutput:
|
||||
"""Test structured output functionality in tools."""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_with_basemodel_output(self):
|
||||
"""Test tool with BaseModel return type."""
|
||||
|
||||
class UserOutput(BaseModel):
|
||||
name: str
|
||||
age: int
|
||||
|
||||
def get_user(user_id: int) -> UserOutput:
|
||||
"""Get user by ID."""
|
||||
return UserOutput(name="John", age=30)
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(get_user)
|
||||
result = await manager.call_tool("get_user", {"user_id": 1}, Context(), convert_result=True)
|
||||
# don't test unstructured output here, just the structured conversion
|
||||
assert isinstance(result, CallToolResult)
|
||||
assert result.structured_content == {"name": "John", "age": 30}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_with_primitive_output(self):
|
||||
"""Test tool with primitive return type."""
|
||||
|
||||
def double_number(n: int) -> int:
|
||||
"""Double a number."""
|
||||
return 10
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(double_number)
|
||||
result = await manager.call_tool("double_number", {"n": 5}, Context())
|
||||
assert result == 10
|
||||
result = await manager.call_tool("double_number", {"n": 5}, Context(), convert_result=True)
|
||||
assert isinstance(result, CallToolResult)
|
||||
assert isinstance(result.content[0], TextContent) and result.structured_content == {"result": 10}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_with_typeddict_output(self):
|
||||
"""Test tool with TypedDict return type."""
|
||||
|
||||
class UserDict(TypedDict):
|
||||
name: str
|
||||
age: int
|
||||
|
||||
expected_output = {"name": "Alice", "age": 25}
|
||||
|
||||
def get_user_dict(user_id: int) -> UserDict:
|
||||
"""Get user as dict."""
|
||||
return UserDict(name="Alice", age=25)
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(get_user_dict)
|
||||
result = await manager.call_tool("get_user_dict", {"user_id": 1}, Context())
|
||||
assert result == expected_output
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_with_dataclass_output(self):
|
||||
"""Test tool with dataclass return type."""
|
||||
|
||||
@dataclass
|
||||
class Person:
|
||||
name: str
|
||||
age: int
|
||||
|
||||
expected_output = {"name": "Bob", "age": 40}
|
||||
|
||||
def get_person() -> Person:
|
||||
"""Get a person."""
|
||||
return Person("Bob", 40)
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(get_person)
|
||||
result = await manager.call_tool("get_person", {}, Context(), convert_result=True)
|
||||
# don't test unstructured output here, just the structured conversion
|
||||
assert isinstance(result, CallToolResult)
|
||||
assert result.structured_content == expected_output
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_with_list_output(self):
|
||||
"""Test tool with list return type."""
|
||||
|
||||
expected_list = [1, 2, 3, 4, 5]
|
||||
expected_output = {"result": expected_list}
|
||||
|
||||
def get_numbers() -> list[int]:
|
||||
"""Get a list of numbers."""
|
||||
return expected_list
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(get_numbers)
|
||||
result = await manager.call_tool("get_numbers", {}, Context())
|
||||
assert result == expected_list
|
||||
result = await manager.call_tool("get_numbers", {}, Context(), convert_result=True)
|
||||
assert isinstance(result, CallToolResult)
|
||||
assert isinstance(result.content[0], TextContent) and result.structured_content == expected_output
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_without_structured_output(self):
|
||||
"""Test that tools work normally when structured_output=False."""
|
||||
|
||||
def get_dict() -> dict[str, Any]:
|
||||
"""Get a dict."""
|
||||
return {"key": "value"}
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(get_dict, structured_output=False)
|
||||
result = await manager.call_tool("get_dict", {}, Context())
|
||||
assert isinstance(result, dict)
|
||||
assert result == {"key": "value"}
|
||||
|
||||
def test_tool_output_schema_property(self):
|
||||
"""Test that Tool.output_schema property works correctly."""
|
||||
|
||||
class UserOutput(BaseModel):
|
||||
name: str
|
||||
age: int
|
||||
|
||||
def get_user() -> UserOutput: # pragma: no cover
|
||||
return UserOutput(name="Test", age=25)
|
||||
|
||||
manager = ToolManager()
|
||||
tool = manager.add_tool(get_user)
|
||||
|
||||
# Test that output_schema is populated
|
||||
expected_schema = {
|
||||
"properties": {"name": {"type": "string", "title": "Name"}, "age": {"type": "integer", "title": "Age"}},
|
||||
"required": ["name", "age"],
|
||||
"title": "UserOutput",
|
||||
"type": "object",
|
||||
}
|
||||
assert tool.output_schema == expected_schema
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_with_dict_str_any_output(self):
|
||||
"""Test tool with dict[str, Any] return type."""
|
||||
|
||||
def get_config() -> dict[str, Any]:
|
||||
"""Get configuration"""
|
||||
return {"debug": True, "port": 8080, "features": ["auth", "logging"]}
|
||||
|
||||
manager = ToolManager()
|
||||
tool = manager.add_tool(get_config)
|
||||
|
||||
# Check output schema
|
||||
assert tool.output_schema is not None
|
||||
assert tool.output_schema["type"] == "object"
|
||||
assert "properties" not in tool.output_schema # dict[str, Any] has no constraints
|
||||
|
||||
# Test raw result
|
||||
result = await manager.call_tool("get_config", {}, Context())
|
||||
expected = {"debug": True, "port": 8080, "features": ["auth", "logging"]}
|
||||
assert result == expected
|
||||
|
||||
# Test converted result
|
||||
result = await manager.call_tool("get_config", {}, Context())
|
||||
assert result == expected
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tool_with_dict_str_typed_output(self):
|
||||
"""Test tool with dict[str, T] return type for specific T."""
|
||||
|
||||
def get_scores() -> dict[str, int]:
|
||||
"""Get player scores"""
|
||||
return {"alice": 100, "bob": 85, "charlie": 92}
|
||||
|
||||
manager = ToolManager()
|
||||
tool = manager.add_tool(get_scores)
|
||||
|
||||
# Check output schema
|
||||
assert tool.output_schema is not None
|
||||
assert tool.output_schema["type"] == "object"
|
||||
assert tool.output_schema["additionalProperties"]["type"] == "integer"
|
||||
|
||||
# Test raw result
|
||||
result = await manager.call_tool("get_scores", {}, Context())
|
||||
expected = {"alice": 100, "bob": 85, "charlie": 92}
|
||||
assert result == expected
|
||||
|
||||
# Test converted result
|
||||
result = await manager.call_tool("get_scores", {}, Context())
|
||||
assert result == expected
|
||||
|
||||
|
||||
class TestToolMetadata:
|
||||
"""Test tool metadata functionality."""
|
||||
|
||||
def test_add_tool_with_metadata(self):
|
||||
"""Test adding a tool with metadata via ToolManager."""
|
||||
|
||||
def process_data(input_data: str) -> str: # pragma: no cover
|
||||
"""Process some data."""
|
||||
return f"Processed: {input_data}"
|
||||
|
||||
metadata = {"ui": {"type": "form", "fields": ["input"]}, "version": "1.0"}
|
||||
|
||||
manager = ToolManager()
|
||||
tool = manager.add_tool(process_data, meta=metadata)
|
||||
|
||||
assert tool.meta is not None
|
||||
assert tool.meta == metadata
|
||||
assert tool.meta["ui"]["type"] == "form"
|
||||
assert tool.meta["version"] == "1.0"
|
||||
|
||||
def test_add_tool_without_metadata(self):
|
||||
"""Test that tools without metadata have None as meta value."""
|
||||
|
||||
def simple_tool(x: int) -> int: # pragma: no cover
|
||||
"""Simple tool."""
|
||||
return x * 2
|
||||
|
||||
manager = ToolManager()
|
||||
tool = manager.add_tool(simple_tool)
|
||||
|
||||
assert tool.meta is None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_metadata_in_mcpserver_decorator(self):
|
||||
"""Test that metadata is correctly added via MCPServer.tool decorator."""
|
||||
|
||||
app = MCPServer()
|
||||
|
||||
metadata = {"client": {"ui_component": "file_picker"}, "priority": "high"}
|
||||
|
||||
@app.tool(meta=metadata)
|
||||
def upload_file(filename: str) -> str: # pragma: no cover
|
||||
"""Upload a file."""
|
||||
return f"Uploaded: {filename}"
|
||||
|
||||
# Get the tool from the tool manager
|
||||
tool = app._tool_manager.get_tool("upload_file")
|
||||
assert tool is not None
|
||||
assert tool.meta is not None
|
||||
assert tool.meta == metadata
|
||||
assert tool.meta["client"]["ui_component"] == "file_picker"
|
||||
assert tool.meta["priority"] == "high"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_metadata_in_list_tools(self):
|
||||
"""Test that metadata is included in MCPTool when listing tools."""
|
||||
|
||||
app = MCPServer()
|
||||
|
||||
metadata = {
|
||||
"ui": {"input_type": "textarea", "rows": 5},
|
||||
"tags": ["text", "processing"],
|
||||
}
|
||||
|
||||
@app.tool(meta=metadata)
|
||||
def analyze_text(text: str) -> dict[str, Any]: # pragma: no cover
|
||||
"""Analyze text content."""
|
||||
return {"length": len(text), "words": len(text.split())}
|
||||
|
||||
tools = await app.list_tools()
|
||||
assert len(tools) == 1
|
||||
assert tools[0].meta is not None
|
||||
assert tools[0].meta == metadata
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_multiple_tools_with_different_metadata(self):
|
||||
"""Test multiple tools with different metadata values."""
|
||||
|
||||
app = MCPServer()
|
||||
|
||||
metadata1 = {"ui": "form", "version": 1}
|
||||
metadata2 = {"ui": "picker", "experimental": True}
|
||||
|
||||
@app.tool(meta=metadata1)
|
||||
def tool1(x: int) -> int: # pragma: no cover
|
||||
"""First tool."""
|
||||
return x
|
||||
|
||||
@app.tool(meta=metadata2)
|
||||
def tool2(y: str) -> str: # pragma: no cover
|
||||
"""Second tool."""
|
||||
return y
|
||||
|
||||
@app.tool()
|
||||
def tool3(z: bool) -> bool: # pragma: no cover
|
||||
"""Third tool without metadata."""
|
||||
return z
|
||||
|
||||
tools = await app.list_tools()
|
||||
assert len(tools) == 3
|
||||
|
||||
# Find tools by name and check metadata
|
||||
tools_by_name = {t.name: t for t in tools}
|
||||
|
||||
assert tools_by_name["tool1"].meta == metadata1
|
||||
assert tools_by_name["tool2"].meta == metadata2
|
||||
assert tools_by_name["tool3"].meta is None
|
||||
|
||||
def test_metadata_with_complex_structure(self):
|
||||
"""Test metadata with complex nested structures."""
|
||||
|
||||
def complex_tool(data: str) -> str: # pragma: no cover
|
||||
"""Tool with complex metadata."""
|
||||
return data
|
||||
|
||||
metadata = {
|
||||
"ui": {
|
||||
"components": [
|
||||
{"type": "input", "name": "field1", "validation": {"required": True, "minLength": 5}},
|
||||
{"type": "select", "name": "field2", "options": ["a", "b", "c"]},
|
||||
],
|
||||
"layout": {"columns": 2, "responsive": True},
|
||||
},
|
||||
"permissions": ["read", "write"],
|
||||
"tags": ["data-processing", "user-input"],
|
||||
"version": 2,
|
||||
}
|
||||
|
||||
manager = ToolManager()
|
||||
tool = manager.add_tool(complex_tool, meta=metadata)
|
||||
|
||||
assert tool.meta is not None
|
||||
assert tool.meta["ui"]["components"][0]["validation"]["minLength"] == 5
|
||||
assert tool.meta["ui"]["layout"]["columns"] == 2
|
||||
assert "read" in tool.meta["permissions"]
|
||||
assert "data-processing" in tool.meta["tags"]
|
||||
|
||||
def test_metadata_empty_dict(self):
|
||||
"""Test that empty dict metadata is preserved."""
|
||||
|
||||
def tool_with_empty_meta(x: int) -> int: # pragma: no cover
|
||||
"""Tool with empty metadata."""
|
||||
return x
|
||||
|
||||
manager = ToolManager()
|
||||
tool = manager.add_tool(tool_with_empty_meta, meta={})
|
||||
|
||||
assert tool.meta is not None
|
||||
assert tool.meta == {}
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_metadata_with_annotations(self):
|
||||
"""Test that metadata and annotations can coexist."""
|
||||
|
||||
app = MCPServer()
|
||||
|
||||
metadata = {"custom": "value"}
|
||||
annotations = ToolAnnotations(title="Combined Tool", read_only_hint=True)
|
||||
|
||||
@app.tool(meta=metadata, annotations=annotations)
|
||||
def combined_tool(data: str) -> str: # pragma: no cover
|
||||
"""Tool with both metadata and annotations."""
|
||||
return data
|
||||
|
||||
tools = await app.list_tools()
|
||||
assert len(tools) == 1
|
||||
assert tools[0].meta == metadata
|
||||
assert tools[0].annotations is not None
|
||||
assert tools[0].annotations.title == "Combined Tool"
|
||||
assert tools[0].annotations.read_only_hint is True
|
||||
|
||||
|
||||
class TestRemoveTools:
|
||||
"""Test tool removal functionality in the tool manager."""
|
||||
|
||||
def test_remove_existing_tool(self):
|
||||
"""Test removing an existing tool."""
|
||||
|
||||
def add(a: int, b: int) -> int: # pragma: no cover
|
||||
"""Add two numbers."""
|
||||
return a + b
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(add)
|
||||
|
||||
# Verify tool exists
|
||||
assert manager.get_tool("add") is not None
|
||||
assert len(manager.list_tools()) == 1
|
||||
|
||||
# Remove the tool - should not raise any exception
|
||||
manager.remove_tool("add")
|
||||
|
||||
# Verify tool is removed
|
||||
assert manager.get_tool("add") is None
|
||||
assert len(manager.list_tools()) == 0
|
||||
|
||||
def test_remove_nonexistent_tool(self):
|
||||
"""Test removing a non-existent tool raises ToolError."""
|
||||
manager = ToolManager()
|
||||
|
||||
with pytest.raises(ToolError, match="Unknown tool: nonexistent"):
|
||||
manager.remove_tool("nonexistent")
|
||||
|
||||
def test_remove_tool_from_multiple_tools(self):
|
||||
"""Test removing one tool when multiple tools exist."""
|
||||
|
||||
def add(a: int, b: int) -> int: # pragma: no cover
|
||||
"""Add two numbers."""
|
||||
return a + b
|
||||
|
||||
def multiply(a: int, b: int) -> int: # pragma: no cover
|
||||
"""Multiply two numbers."""
|
||||
return a * b
|
||||
|
||||
def divide(a: int, b: int) -> float: # pragma: no cover
|
||||
"""Divide two numbers."""
|
||||
return a / b
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(add)
|
||||
manager.add_tool(multiply)
|
||||
manager.add_tool(divide)
|
||||
|
||||
# Verify all tools exist
|
||||
assert len(manager.list_tools()) == 3
|
||||
assert manager.get_tool("add") is not None
|
||||
assert manager.get_tool("multiply") is not None
|
||||
assert manager.get_tool("divide") is not None
|
||||
|
||||
# Remove middle tool
|
||||
manager.remove_tool("multiply")
|
||||
|
||||
# Verify only multiply is removed
|
||||
assert len(manager.list_tools()) == 2
|
||||
assert manager.get_tool("add") is not None
|
||||
assert manager.get_tool("multiply") is None
|
||||
assert manager.get_tool("divide") is not None
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_call_removed_tool_raises_error(self):
|
||||
"""Test that calling a removed tool raises ToolError."""
|
||||
|
||||
def greet(name: str) -> str:
|
||||
"""Greet someone."""
|
||||
return f"Hello, {name}!"
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(greet)
|
||||
|
||||
# Verify tool works before removal
|
||||
result = await manager.call_tool("greet", {"name": "World"}, Context())
|
||||
assert result == "Hello, World!"
|
||||
|
||||
# Remove the tool
|
||||
manager.remove_tool("greet")
|
||||
|
||||
# Verify calling removed tool raises error
|
||||
with pytest.raises(ToolError, match="Unknown tool: greet"):
|
||||
await manager.call_tool("greet", {"name": "World"}, Context())
|
||||
|
||||
def test_remove_tool_case_sensitive(self):
|
||||
"""Test that tool removal is case-sensitive."""
|
||||
|
||||
def test_func() -> str: # pragma: no cover
|
||||
"""Test function."""
|
||||
return "test"
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(test_func)
|
||||
|
||||
# Verify tool exists
|
||||
assert manager.get_tool("test_func") is not None
|
||||
|
||||
# Try to remove with different case - should raise ToolError
|
||||
with pytest.raises(ToolError, match="Unknown tool: Test_Func"):
|
||||
manager.remove_tool("Test_Func")
|
||||
|
||||
# Verify original tool still exists
|
||||
assert manager.get_tool("test_func") is not None
|
||||
|
||||
# Remove with correct case
|
||||
manager.remove_tool("test_func")
|
||||
assert manager.get_tool("test_func") is None
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Test URL mode elicitation feature (SEP 1036)."""
|
||||
|
||||
import anyio
|
||||
import mcp_types as types
|
||||
import pytest
|
||||
from mcp_types import ElicitRequestParams, ElicitResult, TextContent
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from mcp import Client
|
||||
from mcp.client import ClientRequestContext
|
||||
from mcp.server.elicitation import CancelledElicitation, DeclinedElicitation, elicit_url
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_url_elicitation_accept():
|
||||
"""Test URL mode elicitation with user acceptance."""
|
||||
mcp = MCPServer(name="URLElicitationServer")
|
||||
|
||||
@mcp.tool(description="A tool that uses URL elicitation")
|
||||
async def request_api_key(ctx: Context) -> str:
|
||||
result = await ctx.session.elicit_url(
|
||||
message="Please provide your API key to continue.",
|
||||
url="https://example.com/api_key_setup",
|
||||
elicitation_id="test-elicitation-001",
|
||||
)
|
||||
# Test only checks accept path
|
||||
return f"User {result.action}"
|
||||
|
||||
# Create elicitation callback that accepts URL mode
|
||||
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
assert params.mode == "url"
|
||||
assert params.url == "https://example.com/api_key_setup"
|
||||
assert params.elicitation_id == "test-elicitation-001"
|
||||
assert params.message == "Please provide your API key to continue."
|
||||
return ElicitResult(action="accept")
|
||||
|
||||
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
|
||||
result = await client.call_tool("request_api_key", {})
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "User accept"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_url_elicitation_decline():
|
||||
"""Test URL mode elicitation with user declining."""
|
||||
mcp = MCPServer(name="URLElicitationDeclineServer")
|
||||
|
||||
@mcp.tool(description="A tool that uses URL elicitation")
|
||||
async def oauth_flow(ctx: Context) -> str:
|
||||
result = await ctx.session.elicit_url(
|
||||
message="Authorize access to your files.",
|
||||
url="https://example.com/oauth/authorize",
|
||||
elicitation_id="oauth-001",
|
||||
)
|
||||
# Test only checks decline path
|
||||
return f"User {result.action} authorization"
|
||||
|
||||
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
assert params.mode == "url"
|
||||
return ElicitResult(action="decline")
|
||||
|
||||
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
|
||||
result = await client.call_tool("oauth_flow", {})
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "User decline authorization"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_url_elicitation_cancel():
|
||||
"""Test URL mode elicitation with user cancelling."""
|
||||
mcp = MCPServer(name="URLElicitationCancelServer")
|
||||
|
||||
@mcp.tool(description="A tool that uses URL elicitation")
|
||||
async def payment_flow(ctx: Context) -> str:
|
||||
result = await ctx.session.elicit_url(
|
||||
message="Complete payment to proceed.",
|
||||
url="https://example.com/payment",
|
||||
elicitation_id="payment-001",
|
||||
)
|
||||
# Test only checks cancel path
|
||||
return f"User {result.action} payment"
|
||||
|
||||
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
assert params.mode == "url"
|
||||
return ElicitResult(action="cancel")
|
||||
|
||||
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
|
||||
result = await client.call_tool("payment_flow", {})
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "User cancel payment"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_url_elicitation_helper_function():
|
||||
"""Test the elicit_url helper function."""
|
||||
mcp = MCPServer(name="URLElicitationHelperServer")
|
||||
|
||||
@mcp.tool(description="Tool using elicit_url helper")
|
||||
async def setup_credentials(ctx: Context) -> str:
|
||||
result = await elicit_url(
|
||||
session=ctx.session,
|
||||
message="Set up your credentials",
|
||||
url="https://example.com/setup",
|
||||
elicitation_id="setup-001",
|
||||
)
|
||||
# Test only checks accept path - return the type name
|
||||
return type(result).__name__
|
||||
|
||||
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
return ElicitResult(action="accept")
|
||||
|
||||
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
|
||||
result = await client.call_tool("setup_credentials", {})
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "AcceptedUrlElicitation"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_url_no_content_in_response():
|
||||
"""Test that URL mode elicitation responses don't include content field."""
|
||||
mcp = MCPServer(name="URLContentCheckServer")
|
||||
|
||||
@mcp.tool(description="Check URL response format")
|
||||
async def check_url_response(ctx: Context) -> str:
|
||||
result = await ctx.session.elicit_url(
|
||||
message="Test message",
|
||||
url="https://example.com/test",
|
||||
elicitation_id="test-001",
|
||||
)
|
||||
|
||||
# URL mode responses should not have content
|
||||
assert result.content is None
|
||||
return f"Action: {result.action}, Content: {result.content}"
|
||||
|
||||
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
# Verify that this is URL mode
|
||||
assert params.mode == "url"
|
||||
assert isinstance(params, types.ElicitRequestURLParams)
|
||||
# URL params have url and elicitation_id, not requested_schema
|
||||
assert params.url == "https://example.com/test"
|
||||
assert params.elicitation_id == "test-001"
|
||||
# Return without content - this is correct for URL mode
|
||||
return ElicitResult(action="accept")
|
||||
|
||||
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
|
||||
result = await client.call_tool("check_url_response", {})
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert "Content: None" in result.content[0].text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_form_mode_still_works():
|
||||
"""Ensure form mode elicitation still works after SEP 1036."""
|
||||
mcp = MCPServer(name="FormModeBackwardCompatServer")
|
||||
|
||||
class NameSchema(BaseModel):
|
||||
name: str = Field(description="Your name")
|
||||
|
||||
@mcp.tool(description="Test form mode")
|
||||
async def ask_name(ctx: Context) -> str:
|
||||
result = await ctx.elicit(message="What is your name?", schema=NameSchema)
|
||||
# Test only checks accept path with data
|
||||
assert result.action == "accept"
|
||||
assert result.data is not None
|
||||
return f"Hello, {result.data.name}!"
|
||||
|
||||
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
# Verify form mode parameters
|
||||
assert params.mode == "form"
|
||||
assert isinstance(params, types.ElicitRequestFormParams)
|
||||
# Form params have requested_schema, not url/elicitation_id
|
||||
assert params.requested_schema is not None
|
||||
return ElicitResult(action="accept", content={"name": "Alice"})
|
||||
|
||||
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
|
||||
result = await client.call_tool("ask_name", {})
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Hello, Alice!"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_elicit_complete_notification():
|
||||
"""Test that elicitation completion notifications can be sent and received."""
|
||||
mcp = MCPServer(name="ElicitCompleteServer")
|
||||
|
||||
# Track if the notification was sent
|
||||
notification_sent = False
|
||||
|
||||
@mcp.tool(description="Tool that sends completion notification")
|
||||
async def trigger_elicitation(ctx: Context) -> str:
|
||||
nonlocal notification_sent
|
||||
|
||||
# Simulate an async operation (e.g., user completing auth in browser)
|
||||
elicitation_id = "complete-test-001"
|
||||
|
||||
# Send completion notification
|
||||
await ctx.session.send_elicit_complete(elicitation_id)
|
||||
notification_sent = True
|
||||
|
||||
return "Elicitation completed"
|
||||
|
||||
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
return ElicitResult(action="accept") # pragma: no cover
|
||||
|
||||
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
|
||||
result = await client.call_tool("trigger_elicitation", {})
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Elicitation completed"
|
||||
|
||||
# Give time for notification to be processed
|
||||
await anyio.sleep(0.1)
|
||||
|
||||
# Verify the notification was sent
|
||||
assert notification_sent
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_url_elicitation_required_error_code():
|
||||
"""Test that the URL_ELICITATION_REQUIRED error code is correct."""
|
||||
# Verify the error code matches the specification (SEP 1036)
|
||||
assert types.URL_ELICITATION_REQUIRED == -32042, (
|
||||
"URL_ELICITATION_REQUIRED error code must be -32042 per SEP 1036 specification"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_elicit_url_typed_results():
|
||||
"""Test that elicit_url returns properly typed result objects."""
|
||||
mcp = MCPServer(name="TypedResultsServer")
|
||||
|
||||
@mcp.tool(description="Test declined result")
|
||||
async def test_decline(ctx: Context) -> str:
|
||||
result = await elicit_url(
|
||||
session=ctx.session,
|
||||
message="Test decline",
|
||||
url="https://example.com/decline",
|
||||
elicitation_id="decline-001",
|
||||
)
|
||||
|
||||
if isinstance(result, DeclinedElicitation):
|
||||
return "Declined"
|
||||
return "Not declined" # pragma: no cover
|
||||
|
||||
@mcp.tool(description="Test cancelled result")
|
||||
async def test_cancel(ctx: Context) -> str:
|
||||
result = await elicit_url(
|
||||
session=ctx.session,
|
||||
message="Test cancel",
|
||||
url="https://example.com/cancel",
|
||||
elicitation_id="cancel-001",
|
||||
)
|
||||
|
||||
if isinstance(result, CancelledElicitation):
|
||||
return "Cancelled"
|
||||
return "Not cancelled" # pragma: no cover
|
||||
|
||||
# Test declined result
|
||||
async def decline_callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
return ElicitResult(action="decline")
|
||||
|
||||
async with Client(mcp, mode="legacy", elicitation_callback=decline_callback) as client:
|
||||
result = await client.call_tool("test_decline", {})
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Declined"
|
||||
|
||||
# Test cancelled result
|
||||
async def cancel_callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
return ElicitResult(action="cancel")
|
||||
|
||||
async with Client(mcp, mode="legacy", elicitation_callback=cancel_callback) as client:
|
||||
result = await client.call_tool("test_cancel", {})
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Cancelled"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_deprecated_elicit_method():
|
||||
"""Test the deprecated elicit() method for backward compatibility."""
|
||||
mcp = MCPServer(name="DeprecatedElicitServer")
|
||||
|
||||
class EmailSchema(BaseModel):
|
||||
email: str = Field(description="Email address")
|
||||
|
||||
@mcp.tool(description="Test deprecated elicit method")
|
||||
async def use_deprecated_elicit(ctx: Context) -> str:
|
||||
# Use the deprecated elicit() method which should call elicit_form()
|
||||
result = await ctx.session.elicit(
|
||||
message="Enter your email",
|
||||
requested_schema=EmailSchema.model_json_schema(),
|
||||
)
|
||||
|
||||
if result.action == "accept" and result.content:
|
||||
return f"Email: {result.content.get('email', 'none')}"
|
||||
return "No email provided" # pragma: no cover
|
||||
|
||||
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
# Verify this is form mode
|
||||
assert params.mode == "form"
|
||||
assert params.requested_schema is not None
|
||||
return ElicitResult(action="accept", content={"email": "test@example.com"})
|
||||
|
||||
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
|
||||
result = await client.call_tool("use_deprecated_elicit", {})
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Email: test@example.com"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ctx_elicit_url_convenience_method():
|
||||
"""Test the ctx.elicit_url() convenience method (vs ctx.session.elicit_url())."""
|
||||
mcp = MCPServer(name="CtxElicitUrlServer")
|
||||
|
||||
@mcp.tool(description="A tool that uses ctx.elicit_url() directly")
|
||||
async def direct_elicit_url(ctx: Context) -> str:
|
||||
# Use ctx.elicit_url() directly instead of ctx.session.elicit_url()
|
||||
result = await ctx.elicit_url(
|
||||
message="Test the convenience method",
|
||||
url="https://example.com/test",
|
||||
elicitation_id="ctx-test-001",
|
||||
)
|
||||
return f"Result: {result.action}"
|
||||
|
||||
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
|
||||
assert params.mode == "url"
|
||||
assert params.elicitation_id == "ctx-test-001"
|
||||
return ElicitResult(action="accept")
|
||||
|
||||
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
|
||||
result = await client.call_tool("direct_elicit_url", {})
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Result: accept"
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Test that UrlElicitationRequiredError is properly propagated as MCP error."""
|
||||
|
||||
import mcp_types as types
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
from mcp import Client, ErrorData
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from mcp.shared.exceptions import MCPError, UrlElicitationRequiredError
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_url_elicitation_error_thrown_from_tool():
|
||||
"""Test that UrlElicitationRequiredError raised from a tool is received as MCPError by client."""
|
||||
mcp = MCPServer(name="UrlElicitationErrorServer")
|
||||
|
||||
@mcp.tool(description="A tool that raises UrlElicitationRequiredError")
|
||||
async def connect_service(service_name: str, ctx: Context) -> str:
|
||||
# This tool cannot proceed without authorization
|
||||
raise UrlElicitationRequiredError(
|
||||
[
|
||||
types.ElicitRequestURLParams(
|
||||
mode="url",
|
||||
message=f"Authorization required to connect to {service_name}",
|
||||
url=f"https://{service_name}.example.com/oauth/authorize",
|
||||
elicitation_id=f"{service_name}-auth-001",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
async with Client(mcp) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.call_tool("connect_service", {"service_name": "github"})
|
||||
|
||||
assert exc_info.value.error == snapshot(
|
||||
ErrorData(
|
||||
code=types.URL_ELICITATION_REQUIRED,
|
||||
message="URL elicitation required",
|
||||
data={
|
||||
"elicitations": [
|
||||
{
|
||||
"mode": "url",
|
||||
"message": "Authorization required to connect to github",
|
||||
"url": "https://github.example.com/oauth/authorize",
|
||||
"elicitationId": "github-auth-001",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_url_elicitation_error_from_error():
|
||||
"""Test that client can reconstruct UrlElicitationRequiredError from MCPError."""
|
||||
mcp = MCPServer(name="UrlElicitationErrorServer")
|
||||
|
||||
@mcp.tool(description="A tool that raises UrlElicitationRequiredError with multiple elicitations")
|
||||
async def multi_auth(ctx: Context) -> str:
|
||||
raise UrlElicitationRequiredError(
|
||||
[
|
||||
types.ElicitRequestURLParams(
|
||||
mode="url",
|
||||
message="GitHub authorization required",
|
||||
url="https://github.example.com/oauth",
|
||||
elicitation_id="github-auth",
|
||||
),
|
||||
types.ElicitRequestURLParams(
|
||||
mode="url",
|
||||
message="Google Drive authorization required",
|
||||
url="https://drive.google.com/oauth",
|
||||
elicitation_id="gdrive-auth",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
async with Client(mcp) as client:
|
||||
# Call the tool and catch the error
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.call_tool("multi_auth", {})
|
||||
|
||||
# Reconstruct the typed error
|
||||
mcp_error = exc_info.value
|
||||
assert mcp_error.code == types.URL_ELICITATION_REQUIRED
|
||||
|
||||
url_error = UrlElicitationRequiredError.from_error(mcp_error.error)
|
||||
|
||||
# Verify the reconstructed error has both elicitations
|
||||
assert len(url_error.elicitations) == 2
|
||||
assert url_error.elicitations[0].elicitation_id == "github-auth"
|
||||
assert url_error.elicitations[1].elicitation_id == "gdrive-auth"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_normal_exceptions_still_return_error_result():
|
||||
"""Test that normal exceptions still return CallToolResult with is_error=True."""
|
||||
mcp = MCPServer(name="NormalErrorServer")
|
||||
|
||||
@mcp.tool(description="A tool that raises a normal exception")
|
||||
async def failing_tool(ctx: Context) -> str:
|
||||
raise ValueError("Something went wrong")
|
||||
|
||||
async with Client(mcp) as client:
|
||||
# Normal exceptions should be returned as error results, not MCPError
|
||||
result = await client.call_tool("failing_tool", {})
|
||||
assert result.is_error is True
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], types.TextContent)
|
||||
assert "Something went wrong" in result.content[0].text
|
||||
@@ -0,0 +1,57 @@
|
||||
import mcp_types as types
|
||||
import pytest
|
||||
|
||||
from mcp import Client
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from mcp.server.mcpserver.tools.base import Tool
|
||||
from mcp.shared.exceptions import MCPError
|
||||
|
||||
|
||||
def test_context_detected_in_union_annotation():
|
||||
def my_tool(x: int, ctx: Context | None) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
tool = Tool.from_function(my_tool)
|
||||
assert tool.context_kwarg == "ctx"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mcperror_raised_from_a_tool_surfaces_as_a_top_level_jsonrpc_error_with_code_and_data_intact():
|
||||
"""SDK-defined: ``MCPError`` carries JSON-RPC ``ErrorData(code, message, data)``
|
||||
and means "respond with a protocol error". The tool wrapper re-raises it so
|
||||
the kernel writes a top-level JSON-RPC error - ``code`` and ``data`` survive
|
||||
the round-trip rather than being flattened into ``CallToolResult(isError=True)``."""
|
||||
mcp = MCPServer(name="srv")
|
||||
|
||||
@mcp.tool()
|
||||
async def needs_sampling() -> str:
|
||||
raise MCPError(
|
||||
types.MISSING_REQUIRED_CLIENT_CAPABILITY,
|
||||
"sampling capability required",
|
||||
data={"requiredCapabilities": ["sampling"]},
|
||||
)
|
||||
|
||||
async with Client(mcp) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.call_tool("needs_sampling", {})
|
||||
|
||||
assert exc_info.value.error.code == types.MISSING_REQUIRED_CLIENT_CAPABILITY
|
||||
assert exc_info.value.error.data == {"requiredCapabilities": ["sampling"]}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_non_mcperror_exception_raised_from_a_tool_is_wrapped_as_an_is_error_result():
|
||||
"""SDK-defined: ordinary exceptions from a tool body are execution failures
|
||||
the LLM should see, so they become ``CallToolResult(isError=True)`` rather
|
||||
than a protocol-level JSON-RPC error. Pins the other arm of the same branch."""
|
||||
mcp = MCPServer(name="srv")
|
||||
|
||||
@mcp.tool()
|
||||
async def boom() -> str:
|
||||
raise RuntimeError("execution failure")
|
||||
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("boom", {})
|
||||
|
||||
assert isinstance(result, types.CallToolResult)
|
||||
assert result.is_error is True
|
||||
Reference in New Issue
Block a user