test: interaction-model end-to-end suite with a requirements manifest (#2691)
This commit is contained in:
parent
3eb579948a
commit
24725633f1
6 changed files with 14355 additions and 121 deletions
0
tests/interaction/mcpserver/__init__.py
Normal file
0
tests/interaction/mcpserver/__init__.py
Normal file
38
tests/interaction/mcpserver/test_completion.py
Normal file
38
tests/interaction/mcpserver/test_completion.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"""Completion behaviour against MCPServer, driven through the public Client API."""
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from mcp.types import (
|
||||
Completion,
|
||||
CompletionArgument,
|
||||
CompletionContext,
|
||||
CompletionsCapability,
|
||||
PromptReference,
|
||||
ResourceTemplateReference,
|
||||
)
|
||||
from tests.interaction._connect import Connect
|
||||
from tests.interaction._requirements import requirement
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@requirement("mcpserver:completion:capability-auto")
|
||||
async def test_completion_capability_is_advertised_only_when_a_handler_is_registered(connect: Connect) -> None:
|
||||
"""An MCPServer with a registered completion handler advertises the completions capability; one without does not."""
|
||||
with_handler = MCPServer("completer")
|
||||
|
||||
@with_handler.completion()
|
||||
async def complete(
|
||||
ref: PromptReference | ResourceTemplateReference,
|
||||
argument: CompletionArgument,
|
||||
context: CompletionContext | None,
|
||||
) -> Completion | None:
|
||||
"""Registered only so the completions capability is advertised; never called."""
|
||||
raise NotImplementedError
|
||||
|
||||
async with connect(with_handler) as client:
|
||||
assert client.initialize_result.capabilities.completions == CompletionsCapability()
|
||||
|
||||
async with connect(MCPServer("plain")) as client:
|
||||
assert client.initialize_result.capabilities.completions is None
|
||||
271
tests/interaction/mcpserver/test_context.py
Normal file
271
tests/interaction/mcpserver/test_context.py
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
"""The Context convenience methods MCPServer injects into tool functions, observed from the client."""
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mcp import MCPError
|
||||
from mcp.client import ClientRequestContext
|
||||
from mcp.server.elicitation import AcceptedElicitation
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from mcp.types import (
|
||||
METHOD_NOT_FOUND,
|
||||
CallToolResult,
|
||||
ElicitRequestFormParams,
|
||||
ElicitRequestParams,
|
||||
ElicitResult,
|
||||
ErrorData,
|
||||
Implementation,
|
||||
LoggingMessageNotification,
|
||||
LoggingMessageNotificationParams,
|
||||
TextContent,
|
||||
)
|
||||
from tests.interaction._connect import Connect
|
||||
from tests.interaction._helpers import IncomingMessage
|
||||
from tests.interaction._requirements import requirement
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@requirement("mcpserver:context:logging")
|
||||
@requirement("logging:capability:declared")
|
||||
async def test_context_logging_helpers_send_log_notifications(connect: Connect) -> None:
|
||||
"""Each Context logging helper sends a log message notification at the matching severity.
|
||||
|
||||
All four notifications reach the client's logging callback before the tool call returns; none
|
||||
of them carry a logger name unless one is passed explicitly. The server emits these without
|
||||
advertising the logging capability (see the divergence note on logging:capability).
|
||||
"""
|
||||
received: list[LoggingMessageNotificationParams] = []
|
||||
mcp = MCPServer("chatty")
|
||||
|
||||
@mcp.tool()
|
||||
async def narrate(ctx: Context) -> str:
|
||||
await ctx.debug("d")
|
||||
await ctx.info("i")
|
||||
await ctx.warning("w")
|
||||
await ctx.error("e")
|
||||
return "done"
|
||||
|
||||
async def collect(params: LoggingMessageNotificationParams) -> None:
|
||||
received.append(params)
|
||||
|
||||
async with connect(mcp, logging_callback=collect) as client:
|
||||
result = await client.call_tool("narrate", {})
|
||||
advertised_logging = client.initialize_result.capabilities.logging
|
||||
|
||||
assert result == snapshot(CallToolResult(content=[TextContent(text="done")], structured_content={"result": "done"}))
|
||||
assert received == snapshot(
|
||||
[
|
||||
LoggingMessageNotificationParams(level="debug", data="d"),
|
||||
LoggingMessageNotificationParams(level="info", data="i"),
|
||||
LoggingMessageNotificationParams(level="warning", data="w"),
|
||||
LoggingMessageNotificationParams(level="error", data="e"),
|
||||
]
|
||||
)
|
||||
# The spec requires servers that emit log notifications to declare the logging capability.
|
||||
assert advertised_logging is None
|
||||
|
||||
|
||||
@requirement("mcpserver:context:progress")
|
||||
async def test_context_report_progress_sends_progress_notifications(connect: Connect) -> None:
|
||||
"""Context.report_progress sends progress notifications correlated to the calling request.
|
||||
|
||||
The caller's progress callback receives each report, in order, before the tool call returns.
|
||||
"""
|
||||
received: list[tuple[float, float | None, str | None]] = []
|
||||
mcp = MCPServer("worker")
|
||||
|
||||
@mcp.tool()
|
||||
async def crunch(ctx: Context) -> str:
|
||||
await ctx.report_progress(1, 3)
|
||||
await ctx.report_progress(2, 3, "halfway there")
|
||||
return "crunched"
|
||||
|
||||
async def on_progress(progress: float, total: float | None, message: str | None) -> None:
|
||||
received.append((progress, total, message))
|
||||
|
||||
async with connect(mcp) as client:
|
||||
result = await client.call_tool("crunch", {}, progress_callback=on_progress)
|
||||
|
||||
assert result == snapshot(
|
||||
CallToolResult(content=[TextContent(text="crunched")], structured_content={"result": "crunched"})
|
||||
)
|
||||
assert received == snapshot([(1.0, 3.0, None), (2.0, 3.0, "halfway there")])
|
||||
|
||||
|
||||
@requirement("mcpserver:tool:extra")
|
||||
async def test_context_exposes_request_id_and_client_info_to_a_tool(connect: Connect) -> None:
|
||||
"""A tool can read the per-request id and the connecting client's identity through Context.
|
||||
|
||||
The request id is non-empty (its concrete value depends on transport-level sequencing, so the
|
||||
test asserts the value the tool saw is the one returned, rather than pinning the literal); the
|
||||
client info reflects what the caller passed to `Client`.
|
||||
"""
|
||||
mcp = MCPServer("introspector")
|
||||
|
||||
@mcp.tool()
|
||||
async def whoami(ctx: Context) -> str:
|
||||
client_params = ctx.session.client_params
|
||||
assert client_params is not None
|
||||
return f"request {ctx.request_id} from {client_params.client_info.name} {client_params.client_info.version}"
|
||||
|
||||
async with connect(mcp, client_info=Implementation(name="acme-agent", version="9.9.9")) as client:
|
||||
result = await client.call_tool("whoami", {})
|
||||
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
text = result.content[0].text
|
||||
assert text.startswith("request ")
|
||||
assert text.endswith(" from acme-agent 9.9.9")
|
||||
request_id = text.removeprefix("request ").removesuffix(" from acme-agent 9.9.9")
|
||||
assert request_id
|
||||
|
||||
|
||||
@requirement("protocol:progress:no-token")
|
||||
async def test_report_progress_without_a_progress_token_sends_nothing(connect: Connect) -> None:
|
||||
"""When the caller supplied no progress callback, Context.report_progress is a silent no-op.
|
||||
|
||||
The tool also emits one log message as a sentinel: the message handler receives only that,
|
||||
proving the notification pipeline works and no progress notification was sent for the
|
||||
token-less request.
|
||||
"""
|
||||
received: list[IncomingMessage] = []
|
||||
mcp = MCPServer("quiet")
|
||||
|
||||
@mcp.tool()
|
||||
async def mill(ctx: Context) -> str:
|
||||
await ctx.report_progress(1, 3)
|
||||
await ctx.info("milling done")
|
||||
return "milled"
|
||||
|
||||
async def collect(message: IncomingMessage) -> None:
|
||||
received.append(message)
|
||||
|
||||
async with connect(mcp, message_handler=collect) as client:
|
||||
result = await client.call_tool("mill", {})
|
||||
|
||||
assert result == snapshot(
|
||||
CallToolResult(content=[TextContent(text="milled")], structured_content={"result": "milled"})
|
||||
)
|
||||
assert received == snapshot(
|
||||
[LoggingMessageNotification(params=LoggingMessageNotificationParams(level="info", data="milling done"))]
|
||||
)
|
||||
|
||||
|
||||
@requirement("mcpserver:context:elicit")
|
||||
@requirement("tools:call:elicitation-roundtrip")
|
||||
async def test_context_elicit_returns_typed_result(connect: Connect) -> None:
|
||||
"""Context.elicit sends a form elicitation built from a pydantic schema and returns a typed result.
|
||||
|
||||
The client sees the JSON schema generated from the model; the accepted content is validated
|
||||
back into the model and handed to the tool as result.data.
|
||||
"""
|
||||
received: list[ElicitRequestParams] = []
|
||||
mcp = MCPServer("travel")
|
||||
|
||||
class TravelPreferences(BaseModel):
|
||||
destination: str
|
||||
window_seat: bool
|
||||
|
||||
@mcp.tool()
|
||||
async def book_flight(ctx: Context) -> str:
|
||||
answer = await ctx.elicit("Where to?", TravelPreferences)
|
||||
assert isinstance(answer, AcceptedElicitation)
|
||||
return f"{answer.action}: {answer.data.destination} window={answer.data.window_seat}"
|
||||
|
||||
async def answer_form(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
|
||||
received.append(params)
|
||||
return ElicitResult(action="accept", content={"destination": "Lisbon", "window_seat": True})
|
||||
|
||||
async with connect(mcp, elicitation_callback=answer_form) as client:
|
||||
result = await client.call_tool("book_flight", {})
|
||||
|
||||
assert received == snapshot(
|
||||
[
|
||||
ElicitRequestFormParams(
|
||||
_meta={},
|
||||
message="Where to?",
|
||||
requested_schema={
|
||||
"properties": {
|
||||
"destination": {"title": "Destination", "type": "string"},
|
||||
"window_seat": {"title": "Window Seat", "type": "boolean"},
|
||||
},
|
||||
"required": ["destination", "window_seat"],
|
||||
"title": "TravelPreferences",
|
||||
"type": "object",
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
assert result == snapshot(
|
||||
CallToolResult(
|
||||
content=[TextContent(text="accept: Lisbon window=True")],
|
||||
structured_content={"result": "accept: Lisbon window=True"},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@requirement("mcpserver:context:read-resource")
|
||||
async def test_context_read_resource_reads_registered_resource(connect: Connect) -> None:
|
||||
"""Context.read_resource lets a tool read a resource registered on the same server.
|
||||
|
||||
The tool reports the MIME type and content it read, proving the resource function ran and its
|
||||
return value came back through the context.
|
||||
"""
|
||||
mcp = MCPServer("library")
|
||||
|
||||
@mcp.resource("config://app")
|
||||
def app_config() -> str:
|
||||
"""The application configuration."""
|
||||
return "theme = dark"
|
||||
|
||||
@mcp.tool()
|
||||
async def show_config(ctx: Context) -> str:
|
||||
contents = list(await ctx.read_resource("config://app"))
|
||||
return "\n".join(f"{item.mime_type}: {item.content!r}" for item in contents)
|
||||
|
||||
async with connect(mcp) as client:
|
||||
result = await client.call_tool("show_config", {})
|
||||
|
||||
assert result == snapshot(
|
||||
CallToolResult(
|
||||
content=[TextContent(text="text/plain: 'theme = dark'")],
|
||||
structured_content={"result": "text/plain: 'theme = dark'"},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@requirement("logging:message:filtered")
|
||||
async def test_set_logging_level_is_rejected_and_messages_are_never_filtered(connect: Connect) -> None:
|
||||
"""MCPServer does not support logging/setLevel, so log messages are never filtered by severity.
|
||||
|
||||
The request is rejected with METHOD_NOT_FOUND because MCPServer registers no handler for it,
|
||||
and every message a tool emits is delivered regardless of level. The spec says the server
|
||||
should only send messages at or above the configured level; with no way to configure one,
|
||||
everything is sent.
|
||||
"""
|
||||
received: list[LoggingMessageNotificationParams] = []
|
||||
mcp = MCPServer("unfilterable")
|
||||
|
||||
@mcp.tool()
|
||||
async def chatter(ctx: Context) -> str:
|
||||
await ctx.debug("noise")
|
||||
await ctx.error("signal")
|
||||
return "done"
|
||||
|
||||
async def collect(params: LoggingMessageNotificationParams) -> None:
|
||||
received.append(params)
|
||||
|
||||
async with connect(mcp, logging_callback=collect) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.set_logging_level("error")
|
||||
|
||||
await client.call_tool("chatter", {})
|
||||
|
||||
assert exc_info.value.error == snapshot(ErrorData(code=METHOD_NOT_FOUND, message="Method not found"))
|
||||
assert received == snapshot(
|
||||
[
|
||||
LoggingMessageNotificationParams(level="debug", data="noise"),
|
||||
LoggingMessageNotificationParams(level="error", data="signal"),
|
||||
]
|
||||
)
|
||||
195
tests/interaction/mcpserver/test_prompts.py
Normal file
195
tests/interaction/mcpserver/test_prompts.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
"""Prompt interactions against MCPServer, driven through the public Client API."""
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
from mcp import MCPError
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from mcp.types import (
|
||||
ErrorData,
|
||||
GetPromptResult,
|
||||
ListPromptsResult,
|
||||
Prompt,
|
||||
PromptArgument,
|
||||
PromptMessage,
|
||||
TextContent,
|
||||
)
|
||||
from tests.interaction._connect import Connect
|
||||
from tests.interaction._requirements import requirement
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@requirement("mcpserver:prompt:decorated")
|
||||
async def test_list_prompts_derives_arguments_from_signature(connect: Connect) -> None:
|
||||
"""A decorated prompt is listed with arguments derived from the function signature.
|
||||
|
||||
Parameters without a default are required; the description comes from the docstring.
|
||||
"""
|
||||
mcp = MCPServer("prompter")
|
||||
|
||||
@mcp.prompt()
|
||||
def code_review(code: str, style_guide: str = "pep8") -> str:
|
||||
"""Review a piece of code."""
|
||||
raise NotImplementedError # registered for listing only; never rendered
|
||||
|
||||
async with connect(mcp) as client:
|
||||
result = await client.list_prompts()
|
||||
|
||||
assert result == snapshot(
|
||||
ListPromptsResult(
|
||||
prompts=[
|
||||
Prompt(
|
||||
name="code_review",
|
||||
description="Review a piece of code.",
|
||||
arguments=[
|
||||
PromptArgument(name="code", required=True),
|
||||
PromptArgument(name="style_guide", required=False),
|
||||
],
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@requirement("mcpserver:prompt:decorated")
|
||||
async def test_get_prompt_renders_function_return(connect: Connect) -> None:
|
||||
"""The decorated function's string return value is rendered as a single user message."""
|
||||
mcp = MCPServer("prompter")
|
||||
|
||||
@mcp.prompt()
|
||||
def greet(name: str) -> str:
|
||||
"""A personalised greeting."""
|
||||
return f"Say hello to {name}."
|
||||
|
||||
async with connect(mcp) as client:
|
||||
result = await client.get_prompt("greet", {"name": "Ada"})
|
||||
|
||||
assert result == snapshot(
|
||||
GetPromptResult(
|
||||
description="A personalised greeting.",
|
||||
messages=[PromptMessage(role="user", content=TextContent(text="Say hello to Ada."))],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@requirement("mcpserver:prompt:unknown-name")
|
||||
async def test_get_unknown_prompt_is_error(connect: Connect) -> None:
|
||||
"""Getting a prompt name that was never registered fails with a JSON-RPC error.
|
||||
|
||||
The spec reserves -32602 for this case; the SDK reports code 0 (see the divergence note on
|
||||
the requirement).
|
||||
"""
|
||||
mcp = MCPServer("prompter")
|
||||
|
||||
@mcp.prompt()
|
||||
def greet(name: str) -> str:
|
||||
"""A registered prompt; the test requests a different name."""
|
||||
raise NotImplementedError
|
||||
|
||||
async with connect(mcp) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.get_prompt("nope")
|
||||
|
||||
assert exc_info.value.error == snapshot(ErrorData(code=0, message="Unknown prompt: nope"))
|
||||
|
||||
|
||||
@requirement("prompts:get:missing-required-args")
|
||||
async def test_get_prompt_with_a_missing_required_argument_is_an_error(connect: Connect) -> None:
|
||||
"""Getting a prompt without one of its required arguments fails with a JSON-RPC error.
|
||||
|
||||
The missing argument is detected before the prompt function is called, but the spec's -32602
|
||||
Invalid params is reported as error code 0 with the bare exception text (see the divergence
|
||||
note on the requirement).
|
||||
"""
|
||||
mcp = MCPServer("prompter")
|
||||
|
||||
@mcp.prompt()
|
||||
def greet(name: str) -> str:
|
||||
"""A registered prompt; validation rejects the call before the function runs."""
|
||||
raise NotImplementedError
|
||||
|
||||
async with connect(mcp) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.get_prompt("greet")
|
||||
|
||||
assert exc_info.value.error == snapshot(ErrorData(code=0, message="Missing required arguments: {'name'}"))
|
||||
|
||||
|
||||
@requirement("mcpserver:prompt:args-validation")
|
||||
async def test_get_prompt_with_a_wrong_type_argument_is_rejected_before_the_function_runs(connect: Connect) -> None:
|
||||
"""An argument that fails the function signature's type validation is rejected before the function runs.
|
||||
|
||||
The decorated function is wrapped in pydantic's validate_call, so a value that cannot be
|
||||
coerced to the parameter's annotation fails before the body executes. The function body
|
||||
raises NotImplementedError to prove it never ran. The error is wrapped in the SDK's stable
|
||||
rendering-error prefix; the body of the message is raw pydantic output and is not asserted.
|
||||
"""
|
||||
mcp = MCPServer("prompter")
|
||||
|
||||
@mcp.prompt()
|
||||
def repeat(phrase: str, count: int) -> str:
|
||||
"""A registered prompt; type validation rejects the call before the function runs."""
|
||||
raise NotImplementedError
|
||||
|
||||
async with connect(mcp) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.get_prompt("repeat", {"phrase": "hi", "count": "many"})
|
||||
|
||||
assert exc_info.value.error.code == 0
|
||||
assert exc_info.value.error.message.startswith("Error rendering prompt repeat: 1 validation error")
|
||||
|
||||
|
||||
@requirement("mcpserver:prompt:optional-args")
|
||||
async def test_get_prompt_with_an_optional_argument_omitted_uses_the_default(connect: Connect) -> None:
|
||||
"""A prompt rendered without one of its optional arguments uses that parameter's default value."""
|
||||
mcp = MCPServer("prompter")
|
||||
|
||||
@mcp.prompt()
|
||||
def review(code: str, style: str = "pep8") -> str:
|
||||
"""Review a snippet of code against a style guide."""
|
||||
return f"Review {code} per {style}."
|
||||
|
||||
async with connect(mcp) as client:
|
||||
result = await client.get_prompt("review", {"code": "x = 1"})
|
||||
|
||||
assert result == snapshot(
|
||||
GetPromptResult(
|
||||
description="Review a snippet of code against a style guide.",
|
||||
messages=[PromptMessage(role="user", content=TextContent(text="Review x = 1 per pep8."))],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@requirement("mcpserver:prompt:duplicate-name")
|
||||
async def test_registering_a_duplicate_prompt_name_warns_and_keeps_the_first(connect: Connect) -> None:
|
||||
"""Registering a second prompt with an already-used name keeps the first registration.
|
||||
|
||||
The intended behaviour is rejection at registration time; MCPServer instead logs a warning
|
||||
and discards the second registration (see the divergence note on the requirement). The
|
||||
second function is registered via the decorator with an explicit name so the test does not
|
||||
redefine the same function name in this scope.
|
||||
"""
|
||||
mcp = MCPServer("prompter")
|
||||
|
||||
@mcp.prompt()
|
||||
def greet() -> str:
|
||||
"""The first registration; this is the one that wins."""
|
||||
return "first"
|
||||
|
||||
@mcp.prompt(name="greet")
|
||||
def greet_second() -> str:
|
||||
"""Registered with a duplicate name; the registration is discarded so this never runs."""
|
||||
raise NotImplementedError
|
||||
|
||||
async with connect(mcp) as client:
|
||||
listed = await client.list_prompts()
|
||||
result = await client.get_prompt("greet")
|
||||
|
||||
assert [prompt.name for prompt in listed.prompts] == ["greet"]
|
||||
assert result == snapshot(
|
||||
GetPromptResult(
|
||||
description="The first registration; this is the one that wins.",
|
||||
messages=[PromptMessage(role="user", content=TextContent(text="first"))],
|
||||
)
|
||||
)
|
||||
183
tests/interaction/mcpserver/test_resources.py
Normal file
183
tests/interaction/mcpserver/test_resources.py
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
"""Resource interactions against MCPServer, driven through the public Client API."""
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
from mcp import MCPError
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from mcp.types import (
|
||||
ErrorData,
|
||||
ListResourcesResult,
|
||||
ListResourceTemplatesResult,
|
||||
ReadResourceResult,
|
||||
Resource,
|
||||
ResourceTemplate,
|
||||
TextResourceContents,
|
||||
)
|
||||
from tests.interaction._connect import Connect
|
||||
from tests.interaction._requirements import requirement
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@requirement("mcpserver:resource:static")
|
||||
async def test_read_static_resource(connect: Connect) -> None:
|
||||
"""A function registered for a fixed URI is served at that URI with its return value as text."""
|
||||
mcp = MCPServer("library")
|
||||
|
||||
@mcp.resource("config://app")
|
||||
def app_config() -> str:
|
||||
"""The application configuration."""
|
||||
return "theme = dark"
|
||||
|
||||
async with connect(mcp) as client:
|
||||
result = await client.read_resource("config://app")
|
||||
|
||||
assert result == snapshot(
|
||||
ReadResourceResult(
|
||||
contents=[TextResourceContents(uri="config://app", mime_type="text/plain", text="theme = dark")]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@requirement("mcpserver:resource:static")
|
||||
async def test_list_static_and_templated_resources(connect: Connect) -> None:
|
||||
"""Statically-registered resources appear in resources/list; templated ones only in templates/list.
|
||||
|
||||
The name and description are derived from the function name and docstring; the MIME type
|
||||
defaults to text/plain.
|
||||
"""
|
||||
mcp = MCPServer("library")
|
||||
|
||||
@mcp.resource("config://app")
|
||||
def app_config() -> str:
|
||||
"""The application configuration."""
|
||||
raise NotImplementedError # registered for listing only; never read
|
||||
|
||||
@mcp.resource("users://{user_id}/profile")
|
||||
def user_profile(user_id: str) -> str:
|
||||
"""A user's profile."""
|
||||
raise NotImplementedError # registered for listing only; never read
|
||||
|
||||
async with connect(mcp) as client:
|
||||
resources = await client.list_resources()
|
||||
templates = await client.list_resource_templates()
|
||||
|
||||
assert resources == snapshot(
|
||||
ListResourcesResult(
|
||||
resources=[
|
||||
Resource(
|
||||
name="app_config",
|
||||
uri="config://app",
|
||||
description="The application configuration.",
|
||||
mime_type="text/plain",
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
assert templates == snapshot(
|
||||
ListResourceTemplatesResult(
|
||||
resource_templates=[
|
||||
ResourceTemplate(
|
||||
name="user_profile",
|
||||
uri_template="users://{user_id}/profile",
|
||||
description="A user's profile.",
|
||||
mime_type="text/plain",
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@requirement("mcpserver:resource:template")
|
||||
@requirement("resources:read:template-vars")
|
||||
async def test_read_templated_resource(connect: Connect) -> None:
|
||||
"""Reading a URI that matches a registered template invokes the function with the extracted parameters."""
|
||||
mcp = MCPServer("library")
|
||||
|
||||
@mcp.resource("users://{user_id}/profile")
|
||||
def user_profile(user_id: str) -> str:
|
||||
"""A user's profile."""
|
||||
return f"profile for {user_id}"
|
||||
|
||||
async with connect(mcp) as client:
|
||||
result = await client.read_resource("users://42/profile")
|
||||
|
||||
assert result == snapshot(
|
||||
ReadResourceResult(
|
||||
contents=[TextResourceContents(uri="users://42/profile", mime_type="text/plain", text="profile for 42")]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@requirement("mcpserver:resource:unknown-uri")
|
||||
async def test_read_unknown_uri_is_error(connect: Connect) -> None:
|
||||
"""Reading a URI that matches no registered resource fails with a JSON-RPC error.
|
||||
|
||||
The spec reserves -32002 for resource-not-found; see the divergence note on the requirement.
|
||||
"""
|
||||
mcp = MCPServer("library")
|
||||
|
||||
@mcp.resource("config://app")
|
||||
def app_config() -> str:
|
||||
"""A registered resource; the test reads a different URI."""
|
||||
raise NotImplementedError
|
||||
|
||||
async with connect(mcp) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.read_resource("config://missing")
|
||||
|
||||
assert exc_info.value.error == snapshot(ErrorData(code=0, message="Unknown resource: config://missing"))
|
||||
|
||||
|
||||
@requirement("mcpserver:resource:read-throws-surfaced")
|
||||
async def test_resource_function_that_raises_is_surfaced_as_a_jsonrpc_error(connect: Connect) -> None:
|
||||
"""An exception raised by a resource function reaches the caller as a JSON-RPC error.
|
||||
|
||||
MCPServer wraps the failure in a generic error that names only the URI, so the original
|
||||
exception text is not leaked to the client. The wrapped exception becomes error code 0 the
|
||||
same way every other unhandled server-side exception does.
|
||||
"""
|
||||
mcp = MCPServer("library")
|
||||
|
||||
@mcp.resource("res://boom")
|
||||
def boom() -> str:
|
||||
raise RuntimeError("nope")
|
||||
|
||||
async with connect(mcp) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.read_resource("res://boom")
|
||||
|
||||
assert exc_info.value.error == snapshot(ErrorData(code=0, message="Error reading resource res://boom"))
|
||||
|
||||
|
||||
@requirement("mcpserver:resource:duplicate-name")
|
||||
async def test_registering_a_duplicate_resource_uri_warns_and_keeps_the_first(connect: Connect) -> None:
|
||||
"""Registering a second static resource at an already-used URI keeps the first registration.
|
||||
|
||||
The intended behaviour is rejection at registration time; MCPServer instead logs a warning
|
||||
and discards the second registration (see the divergence note on the requirement). The two
|
||||
registrations use different function names so the test does not redefine a name in this scope;
|
||||
the resource decorator keys on the URI, not the function name.
|
||||
"""
|
||||
mcp = MCPServer("library")
|
||||
|
||||
@mcp.resource("config://app")
|
||||
def config_first() -> str:
|
||||
"""The first registration; this is the one that wins."""
|
||||
return "first"
|
||||
|
||||
@mcp.resource("config://app")
|
||||
def config_second() -> str:
|
||||
"""Registered at a duplicate URI; the registration is discarded so this never runs."""
|
||||
raise NotImplementedError
|
||||
|
||||
async with connect(mcp) as client:
|
||||
listed = await client.list_resources()
|
||||
result = await client.read_resource("config://app")
|
||||
|
||||
assert [resource.uri for resource in listed.resources] == ["config://app"]
|
||||
assert listed.resources[0].name == "config_first"
|
||||
assert result == snapshot(
|
||||
ReadResourceResult(contents=[TextResourceContents(uri="config://app", mime_type="text/plain", text="first")])
|
||||
)
|
||||
432
tests/interaction/mcpserver/test_tools.py
Normal file
432
tests/interaction/mcpserver/test_tools.py
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
"""Tool interactions against MCPServer, driven through the public Client API."""
|
||||
|
||||
import logging
|
||||
from typing import Annotated, Literal
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from mcp import MCPError
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from mcp.server.mcpserver.exceptions import ToolError
|
||||
from mcp.shared.exceptions import UrlElicitationRequiredError
|
||||
from mcp.types import (
|
||||
URL_ELICITATION_REQUIRED,
|
||||
CallToolResult,
|
||||
ElicitRequestURLParams,
|
||||
ErrorData,
|
||||
LoggingMessageNotification,
|
||||
LoggingMessageNotificationParams,
|
||||
TextContent,
|
||||
)
|
||||
from tests.interaction._connect import Connect
|
||||
from tests.interaction._helpers import IncomingMessage
|
||||
from tests.interaction._requirements import requirement
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@requirement("tools:call:content:text")
|
||||
async def test_call_tool_returns_text_content(connect: Connect) -> None:
|
||||
"""Arguments reach the tool function; its return value comes back as text content.
|
||||
|
||||
MCPServer also derives an output schema from the return annotation and attaches the
|
||||
matching structuredContent to the result.
|
||||
"""
|
||||
mcp = MCPServer("adder")
|
||||
|
||||
@mcp.tool()
|
||||
def add(a: int, b: int) -> str:
|
||||
return str(a + b)
|
||||
|
||||
async with connect(mcp) as client:
|
||||
result = await client.call_tool("add", {"a": 2, "b": 3})
|
||||
|
||||
assert result == snapshot(CallToolResult(content=[TextContent(text="5")], structured_content={"result": "5"}))
|
||||
|
||||
|
||||
@requirement("mcpserver:tool:schema-variants")
|
||||
async def test_complex_parameter_types_are_validated_and_coerced_before_the_tool_runs(connect: Connect) -> None:
|
||||
"""Literal, nested-model, and constrained parameters are validated and coerced from the wire arguments.
|
||||
|
||||
The string "3" is coerced to `int` and the `point` dict to a `Point` instance before the function
|
||||
body sees them, proving the generated input schema and validation pipeline cover non-trivial types.
|
||||
"""
|
||||
mcp = MCPServer("typed")
|
||||
|
||||
class Point(BaseModel):
|
||||
x: int
|
||||
y: int
|
||||
|
||||
@mcp.tool()
|
||||
def place(mode: Literal["fast", "slow"], point: Point, count: Annotated[int, Field(ge=1, le=10)]) -> str:
|
||||
assert isinstance(point, Point)
|
||||
return f"{mode} at ({point.x}, {point.y}) x{count}"
|
||||
|
||||
async with connect(mcp) as client:
|
||||
result = await client.call_tool("place", {"mode": "fast", "point": {"x": "3", "y": 4}, "count": 5})
|
||||
|
||||
assert result == snapshot(
|
||||
CallToolResult(
|
||||
content=[TextContent(text="fast at (3, 4) x5")], structured_content={"result": "fast at (3, 4) x5"}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@requirement("mcpserver:tool:handler-throws")
|
||||
@requirement("mcpserver:output-schema:skip-on-error")
|
||||
async def test_call_tool_function_exception_becomes_error_result(connect: Connect) -> None:
|
||||
"""An exception raised by a tool function is returned as an is_error result, not a JSON-RPC error.
|
||||
|
||||
The function's `-> str` annotation gives the tool a derived output schema, but the error
|
||||
result is built before any schema validation runs, so no validation failure is layered on
|
||||
top of the original exception.
|
||||
"""
|
||||
mcp = MCPServer("errors")
|
||||
|
||||
@mcp.tool()
|
||||
def explode() -> str:
|
||||
raise ValueError("boom")
|
||||
|
||||
async with connect(mcp) as client:
|
||||
result = await client.call_tool("explode", {})
|
||||
|
||||
assert result == snapshot(
|
||||
CallToolResult(content=[TextContent(text="Error executing tool explode: boom")], is_error=True)
|
||||
)
|
||||
|
||||
|
||||
@requirement("mcpserver:tool:handler-throws")
|
||||
async def test_call_tool_tool_error_becomes_error_result(connect: Connect) -> None:
|
||||
"""A ToolError raised by a tool function is returned as an is_error result, not a JSON-RPC error."""
|
||||
mcp = MCPServer("errors")
|
||||
|
||||
@mcp.tool()
|
||||
def flux() -> str:
|
||||
raise ToolError("flux capacitor offline")
|
||||
|
||||
async with connect(mcp) as client:
|
||||
result = await client.call_tool("flux", {})
|
||||
|
||||
assert result == snapshot(
|
||||
CallToolResult(content=[TextContent(text="Error executing tool flux: flux capacitor offline")], is_error=True)
|
||||
)
|
||||
|
||||
|
||||
@requirement("mcpserver:tool:unknown-name")
|
||||
async def test_call_tool_unknown_name_returns_error_result(connect: Connect) -> None:
|
||||
"""Calling a tool name that was never registered is reported as an is_error result.
|
||||
|
||||
The spec classifies unknown tools as a protocol error; see the divergence note on the
|
||||
requirement.
|
||||
"""
|
||||
mcp = MCPServer("errors")
|
||||
|
||||
@mcp.tool()
|
||||
def add() -> None:
|
||||
"""A registered tool; the test calls a different name."""
|
||||
|
||||
async with connect(mcp) as client:
|
||||
result = await client.call_tool("nope", {})
|
||||
|
||||
assert result == snapshot(CallToolResult(content=[TextContent(text="Unknown tool: nope")], is_error=True))
|
||||
|
||||
|
||||
@requirement("mcpserver:tool:output-schema:model")
|
||||
@requirement("tools:call:structured-content:text-mirror")
|
||||
async def test_call_tool_model_return_becomes_structured_content(connect: Connect) -> None:
|
||||
"""A tool returning a pydantic model advertises the model's schema as the tool's output schema
|
||||
and returns the model's fields as structured content alongside a serialised text block.
|
||||
"""
|
||||
mcp = MCPServer("weather")
|
||||
|
||||
class Weather(BaseModel):
|
||||
temperature: float
|
||||
conditions: str
|
||||
|
||||
@mcp.tool()
|
||||
def get_weather() -> Weather:
|
||||
return Weather(temperature=22.5, conditions="sunny")
|
||||
|
||||
async with connect(mcp) as client:
|
||||
listed = await client.list_tools()
|
||||
result = await client.call_tool("get_weather", {})
|
||||
|
||||
assert listed.tools[0].output_schema == snapshot(
|
||||
{
|
||||
"properties": {
|
||||
"temperature": {"title": "Temperature", "type": "number"},
|
||||
"conditions": {"title": "Conditions", "type": "string"},
|
||||
},
|
||||
"required": ["temperature", "conditions"],
|
||||
"title": "Weather",
|
||||
"type": "object",
|
||||
}
|
||||
)
|
||||
assert result == snapshot(
|
||||
CallToolResult(
|
||||
content=[
|
||||
TextContent(
|
||||
text="""\
|
||||
{
|
||||
"temperature": 22.5,
|
||||
"conditions": "sunny"
|
||||
}\
|
||||
"""
|
||||
)
|
||||
],
|
||||
structured_content={"temperature": 22.5, "conditions": "sunny"},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@requirement("mcpserver:tool:output-schema:wrapped")
|
||||
async def test_call_tool_list_return_is_wrapped_in_result_key(connect: Connect) -> None:
|
||||
"""A tool returning a list wraps the value under a "result" key in both the generated output
|
||||
schema and the structured content.
|
||||
"""
|
||||
mcp = MCPServer("primes")
|
||||
|
||||
@mcp.tool()
|
||||
def primes() -> list[int]:
|
||||
return [2, 3, 5]
|
||||
|
||||
async with connect(mcp) as client:
|
||||
listed = await client.list_tools()
|
||||
result = await client.call_tool("primes", {})
|
||||
|
||||
assert listed.tools[0].output_schema == snapshot(
|
||||
{
|
||||
"properties": {"result": {"items": {"type": "integer"}, "title": "Result", "type": "array"}},
|
||||
"required": ["result"],
|
||||
"title": "primesOutput",
|
||||
"type": "object",
|
||||
}
|
||||
)
|
||||
assert result == snapshot(
|
||||
CallToolResult(
|
||||
content=[TextContent(text="2"), TextContent(text="3"), TextContent(text="5")],
|
||||
structured_content={"result": [2, 3, 5]},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@requirement("mcpserver:tool:input-validation")
|
||||
async def test_call_tool_invalid_arguments_become_error_result(connect: Connect) -> None:
|
||||
"""Arguments that fail validation against the tool's signature are reported as an is_error
|
||||
result describing the failure, not as a protocol error.
|
||||
"""
|
||||
mcp = MCPServer("adder")
|
||||
|
||||
@mcp.tool()
|
||||
def add(a: int, b: int) -> str:
|
||||
"""Validation rejects the arguments before the function is ever called."""
|
||||
raise NotImplementedError
|
||||
|
||||
async with connect(mcp) as client:
|
||||
result = await client.call_tool("add", {"b": 3})
|
||||
|
||||
# The description is raw pydantic output -- it embeds a pydantic-version-specific
|
||||
# errors.pydantic.dev URL and the internal `addArguments` model name -- so only the stable
|
||||
# prefix is asserted; a full snapshot would break on every pydantic upgrade.
|
||||
assert result.is_error is True
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text.startswith("Error executing tool add: 1 validation error")
|
||||
|
||||
|
||||
@requirement("mcpserver:output-schema:server-validate")
|
||||
@requirement("mcpserver:output-schema:missing-structured")
|
||||
async def test_tool_with_output_schema_returning_mismatched_structured_content_is_an_error_result(
|
||||
connect: Connect,
|
||||
) -> None:
|
||||
"""Structured content that fails the tool's own output schema is rejected on the server side.
|
||||
|
||||
A tool annotated `Annotated[CallToolResult, Model]` returns a hand-built CallToolResult while
|
||||
declaring `Model` as its output schema; MCPServer validates the supplied structured_content
|
||||
against that schema before returning. The two cases -- a content shape that does not match,
|
||||
and no structured content at all -- both fail that validation and are reported as is_error
|
||||
results carrying the (raw pydantic) validation error wrapped in the SDK's stable prefix.
|
||||
"""
|
||||
mcp = MCPServer("forecaster")
|
||||
|
||||
class Weather(BaseModel):
|
||||
temperature: float
|
||||
conditions: str
|
||||
|
||||
@mcp.tool()
|
||||
def mismatched() -> Annotated[CallToolResult, Weather]:
|
||||
return CallToolResult(content=[TextContent(text="oops")], structured_content={"nope": True})
|
||||
|
||||
@mcp.tool()
|
||||
def missing() -> Annotated[CallToolResult, Weather]:
|
||||
return CallToolResult(content=[TextContent(text="oops")])
|
||||
|
||||
async with connect(mcp) as client:
|
||||
mismatched_result = await client.call_tool("mismatched", {})
|
||||
missing_result = await client.call_tool("missing", {})
|
||||
|
||||
# The body of each message is raw pydantic ValidationError output (model name, field paths,
|
||||
# an errors.pydantic.dev URL) and changes across pydantic versions, so only the SDK's stable
|
||||
# prefix is asserted.
|
||||
assert mismatched_result.is_error is True
|
||||
assert isinstance(mismatched_result.content[0], TextContent)
|
||||
assert mismatched_result.content[0].text.startswith("Error executing tool mismatched: 2 validation errors")
|
||||
|
||||
assert missing_result.is_error is True
|
||||
assert isinstance(missing_result.content[0], TextContent)
|
||||
assert missing_result.content[0].text.startswith("Error executing tool missing: 1 validation error")
|
||||
|
||||
|
||||
@requirement("mcpserver:tool:duplicate-name")
|
||||
async def test_registering_a_duplicate_tool_name_warns_and_keeps_the_first(connect: Connect) -> None:
|
||||
"""Registering a second tool with an already-used name keeps the first registration.
|
||||
|
||||
The intended behaviour is rejection at registration time; MCPServer instead logs a warning
|
||||
and discards the second registration (see the divergence note on the requirement). The
|
||||
second function is registered via add_tool with an explicit name so the test does not
|
||||
redefine the same function name in this scope.
|
||||
"""
|
||||
mcp = MCPServer("duplicates")
|
||||
|
||||
@mcp.tool()
|
||||
def echo() -> str:
|
||||
return "first"
|
||||
|
||||
def echo_second() -> str:
|
||||
"""Passed to add_tool with a duplicate name; the registration is discarded so this never runs."""
|
||||
raise NotImplementedError
|
||||
|
||||
mcp.add_tool(echo_second, name="echo")
|
||||
|
||||
async with connect(mcp) as client:
|
||||
listed = await client.list_tools()
|
||||
result = await client.call_tool("echo", {})
|
||||
|
||||
assert [tool.name for tool in listed.tools] == ["echo"]
|
||||
assert result == snapshot(
|
||||
CallToolResult(content=[TextContent(text="first")], structured_content={"result": "first"})
|
||||
)
|
||||
|
||||
|
||||
@requirement("mcpserver:tool:naming-validation")
|
||||
async def test_registering_a_tool_with_a_spec_invalid_name_warns_but_does_not_reject(
|
||||
connect: Connect, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A tool name that violates the SEP-986 rules logs a warning at registration but is still registered.
|
||||
|
||||
The intended behaviour is rejection at registration time; MCPServer instead logs the
|
||||
naming-rule violation and proceeds (see the divergence note on the requirement). The warning
|
||||
spans several SDK-authored log records, so only the stable prefix and inclusion of the
|
||||
offending name are asserted.
|
||||
"""
|
||||
mcp = MCPServer("naming")
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="mcp.shared.tool_name_validation"):
|
||||
|
||||
@mcp.tool(name="bad name!")
|
||||
def bad() -> str:
|
||||
return "ok"
|
||||
|
||||
assert any(
|
||||
rec.levelno == logging.WARNING
|
||||
and rec.message.startswith("Tool name validation warning")
|
||||
and "bad name!" in rec.message
|
||||
for rec in caplog.records
|
||||
)
|
||||
|
||||
async with connect(mcp) as client:
|
||||
listed = await client.list_tools()
|
||||
result = await client.call_tool("bad name!", {})
|
||||
|
||||
assert [tool.name for tool in listed.tools] == ["bad name!"]
|
||||
assert result == snapshot(CallToolResult(content=[TextContent(text="ok")], structured_content={"result": "ok"}))
|
||||
|
||||
|
||||
@requirement("mcpserver:tool:url-elicitation-error")
|
||||
async def test_decorated_tool_raising_url_elicitation_required_surfaces_as_error_32042(connect: Connect) -> None:
|
||||
"""A decorated tool raising the URL-elicitation-required error reaches the client as error -32042.
|
||||
|
||||
MCPServer wraps every other tool exception as an is_error result; this error is special-cased
|
||||
so it propagates as the JSON-RPC error the client needs in order to present the listed URL
|
||||
interactions and retry the call.
|
||||
"""
|
||||
mcp = MCPServer("authorizer")
|
||||
|
||||
@mcp.tool()
|
||||
def read_files() -> str:
|
||||
raise UrlElicitationRequiredError(
|
||||
[
|
||||
ElicitRequestURLParams(
|
||||
message="Authorization required for your files.",
|
||||
url="https://example.com/oauth/authorize",
|
||||
elicitation_id="auth-001",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
async with connect(mcp) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.call_tool("read_files", {})
|
||||
|
||||
assert exc_info.value.error.code == URL_ELICITATION_REQUIRED
|
||||
assert exc_info.value.error == snapshot(
|
||||
ErrorData(
|
||||
code=-32042,
|
||||
message="URL elicitation required",
|
||||
data={
|
||||
"elicitations": [
|
||||
{
|
||||
"mode": "url",
|
||||
"message": "Authorization required for your files.",
|
||||
"url": "https://example.com/oauth/authorize",
|
||||
"elicitationId": "auth-001",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@requirement("mcpserver:register:post-connect")
|
||||
async def test_adding_and_removing_tools_does_not_notify_connected_clients(connect: Connect) -> None:
|
||||
"""Mutating the tool set on a running server changes tools/list but sends no notification.
|
||||
|
||||
add_tool and remove_tool only update the registry: a connected client that listed the tools
|
||||
before the mutation has no way to learn it should list them again. The spec provides
|
||||
notifications/tools/list_changed for exactly this; MCPServer never sends it. The tool emits
|
||||
one log message as a sentinel so the test proves notifications do reach the collector -- the
|
||||
log message arrives, a list_changed does not.
|
||||
"""
|
||||
received: list[IncomingMessage] = []
|
||||
mcp = MCPServer("mutable")
|
||||
|
||||
def extra() -> str:
|
||||
"""A tool registered at runtime; never called."""
|
||||
raise NotImplementedError
|
||||
|
||||
@mcp.tool()
|
||||
def doomed() -> str:
|
||||
"""A tool removed at runtime; never called."""
|
||||
raise NotImplementedError
|
||||
|
||||
@mcp.tool()
|
||||
async def grow(ctx: Context) -> str:
|
||||
mcp.add_tool(extra, name="extra")
|
||||
mcp.remove_tool("doomed")
|
||||
await ctx.info("tool set changed")
|
||||
return "mutated"
|
||||
|
||||
async def collect(message: IncomingMessage) -> None:
|
||||
received.append(message)
|
||||
|
||||
async with connect(mcp, message_handler=collect) as client:
|
||||
before = await client.list_tools()
|
||||
await client.call_tool("grow", {})
|
||||
after = await client.list_tools()
|
||||
|
||||
assert [tool.name for tool in before.tools] == ["doomed", "grow"]
|
||||
assert [tool.name for tool in after.tools] == ["grow", "extra"]
|
||||
assert received == snapshot(
|
||||
[LoggingMessageNotification(params=LoggingMessageNotificationParams(level="info", data="tool set changed"))]
|
||||
)
|
||||
Loading…
Reference in a new issue