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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:10:27 +08:00
commit 49b9bb6724
992 changed files with 161690 additions and 0 deletions
View File
+1
View File
@@ -0,0 +1 @@
"""Tests for the MCP server auth components."""
File diff suppressed because it is too large Load Diff
+245
View File
@@ -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"
+405
View File
@@ -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")
+441
View File
@@ -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
+349
View File
@@ -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
+230
View File
@@ -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"
+906
View File
@@ -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
+57
View File
@@ -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