Files
modelcontextprotocol--pytho…/tests/interaction/mcpserver/test_context.py
T
wehub-resource-sync 49b9bb6724
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
chore: import upstream snapshot with attribution
2026-07-13 12:10:27 +08:00

275 lines
11 KiB
Python

"""The Context convenience methods MCPServer injects into tool functions, observed from the client."""
import pytest
from inline_snapshot import snapshot
from mcp_types import (
METHOD_NOT_FOUND,
CallToolResult,
ElicitRequestFormParams,
ElicitRequestParams,
ElicitResult,
ErrorData,
Implementation,
LoggingMessageNotification,
LoggingMessageNotificationParams,
TextContent,
)
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 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") # pyright: ignore[reportDeprecated]
await ctx.info("i") # pyright: ignore[reportDeprecated]
await ctx.warning("w") # pyright: ignore[reportDeprecated]
await ctx.error("e") # pyright: ignore[reportDeprecated]
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.server_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("mcpserver:context:logging")
@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") # pyright: ignore[reportDeprecated]
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") # pyright: ignore[reportDeprecated]
await ctx.error("signal") # pyright: ignore[reportDeprecated]
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") # pyright: ignore[reportDeprecated]
await client.call_tool("chatter", {})
assert exc_info.value.error == snapshot(
ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="logging/setLevel")
)
assert received == snapshot(
[
LoggingMessageNotificationParams(level="debug", data="noise"),
LoggingMessageNotificationParams(level="error", data="signal"),
]
)