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
@@ -0,0 +1,38 @@
"""Completion behaviour against MCPServer, driven through the public Client API."""
import pytest
from mcp_types import (
Completion,
CompletionArgument,
CompletionContext,
CompletionsCapability,
PromptReference,
ResourceTemplateReference,
)
from mcp.server.mcpserver import MCPServer
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.server_capabilities.completions == CompletionsCapability()
async with connect(MCPServer("plain")) as client:
assert client.server_capabilities.completions is None
+274
View File
@@ -0,0 +1,274 @@
"""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"),
]
)
@@ -0,0 +1,174 @@
"""Client extensions (SEP-2133) over the full client-server loop: a server extension
substitutes a claimed `tools/call` shape and the declaring client's `ClientExtension` resolves it."""
from collections.abc import Awaitable, Callable, Sequence
from typing import Any, Literal
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import MISSING_REQUIRED_CLIENT_CAPABILITY, CallToolResult, Result, TextContent
from pydantic import ValidationError
from mcp import MCPError
from mcp.client import ClaimContext, ClientExtension, ResultClaim, advertise
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
from mcp.server.extension import Extension
from mcp.server.mcpserver import Context, MCPServer, require_client_extension
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
_RECEIPTS = "com.example/receipts"
_FLAGS = "com.example/flags"
class ReceiptResult(Result):
result_type: Literal["receipt"] = "receipt"
receipt_token: str
settings_echo: dict[str, Any] | None = None
_Resolver = Callable[[ReceiptResult, ClaimContext], Awaitable[CallToolResult]]
class Receipts(ClientExtension):
"""Client half: claims the `receipt` shape with the test's resolver and settings."""
identifier = _RECEIPTS
def __init__(self, resolve: _Resolver, settings: dict[str, Any] | None = None) -> None:
self._resolve = resolve
self._settings = {} if settings is None else settings
def settings(self) -> dict[str, Any]:
return self._settings
def claims(self) -> Sequence[ResultClaim[Any]]:
return [ResultClaim(result_type="receipt", model=ReceiptResult, resolve=self._resolve)]
class _ReceiptIssuer(Extension):
"""Server half: answers `buy` with the claimed shape; every other tool passes through."""
identifier = _RECEIPTS
async def intercept_tool_call(
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
) -> HandlerResult:
if params.name != "buy":
return await call_next(ctx)
return {"resultType": "receipt", "receiptToken": "r-117"}
def _receipt_shop(issuer: Extension) -> MCPServer:
server = MCPServer("shop", extensions=[issuer])
@server.tool()
def buy(item: str) -> CallToolResult:
"""Buy an item."""
raise NotImplementedError # the server extension answers `buy` before the tool runs
@server.tool()
def redeem(token: str) -> str:
"""Exchange a receipt token for the goods."""
return f"goods for {token}"
return server
@requirement("extensions:client:claimed-result-resolved")
async def test_claimed_result_is_finished_by_the_owning_extensions_resolver(connect: Connect) -> None:
"""The owning extension's claim resolver redeems the substituted `receipt` through
`ctx.session`, and `call_tool` returns the resolver's plain `CallToolResult`."""
received: list[ReceiptResult] = []
async def redeem_receipt(claimed: ReceiptResult, ctx: ClaimContext) -> CallToolResult:
received.append(claimed)
return await ctx.session.call_tool("redeem", {"token": claimed.receipt_token})
async with connect(_receipt_shop(_ReceiptIssuer()), extensions=[Receipts(redeem_receipt)]) as client:
result = await client.call_tool("buy", {"item": "lamp"})
assert [claimed.receipt_token for claimed in received] == ["r-117"]
assert result == snapshot(
CallToolResult(content=[TextContent(text="goods for r-117")], structured_content={"result": "goods for r-117"})
)
@requirement("extensions:client:claimed-result-undeclared-invalid")
async def test_claimed_shape_fails_validation_for_a_client_without_the_extension(connect: Connect) -> None:
"""Spec-mandated: an unrecognized `resultType` is invalid, so a client without the
owning extension fails to parse the claimed shape."""
async with connect(_receipt_shop(_ReceiptIssuer())) as client:
with pytest.raises(ValidationError):
await client.call_tool("buy", {"item": "lamp"})
class _SettingsEchoIssuer(Extension):
"""Server half: requires the declaring client, then echoes its declared settings."""
identifier = _RECEIPTS
async def intercept_tool_call(
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
) -> HandlerResult:
require_client_extension(ctx, _RECEIPTS)
client_params = ctx.session.client_params
assert client_params is not None
extensions = client_params.capabilities.extensions
assert extensions is not None
return {"resultType": "receipt", "receiptToken": "echo", "settingsEcho": extensions[_RECEIPTS]}
@requirement("extensions:client:capability-ad:gates-server-behaviour")
async def test_per_request_ad_carries_settings_and_gates_the_claimed_substitution(connect: Connect) -> None:
"""The per-request `_meta` capability ad gates the claimed substitution: declared
settings reach the resolver and a non-declaring client is refused with -32021."""
server = MCPServer("shop", extensions=[_SettingsEchoIssuer()])
@server.tool()
def buy(item: str) -> CallToolResult:
"""Buy an item."""
raise NotImplementedError # the server extension answers `buy` before the tool runs
received: list[ReceiptResult] = []
async def keep(claimed: ReceiptResult, ctx: ClaimContext) -> CallToolResult:
received.append(claimed)
return CallToolResult(content=[TextContent(text="done")])
async with connect(server, extensions=[Receipts(keep, settings={"tier": "gold"})]) as client:
result = await client.call_tool("buy", {"item": "lamp"})
assert result.content == [TextContent(text="done")]
assert [claimed.settings_echo for claimed in received] == [{"tier": "gold"}]
async with connect(server) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("buy", {"item": "lamp"})
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
async def _unreachable_resolve(claimed: ReceiptResult, ctx: ClaimContext) -> CallToolResult:
raise NotImplementedError # no claimed shape can be delivered on a legacy wire
@requirement("extensions:client:capability-ad:legacy-omits-claimed")
async def test_legacy_ad_omits_claim_bearing_identifiers_but_keeps_claim_less_ones(connect: Connect) -> None:
"""On a legacy connection the claim-bearing identifier drops out of the initialize
capability ad while an ad-only identifier still advertises."""
server = MCPServer("introspector")
@server.tool()
def declared(ctx: Context) -> list[str]:
"""Report the extension identifiers the client advertised."""
capabilities = ctx.client_capabilities
assert capabilities is not None
return sorted(capabilities.extensions or {})
client_extensions = [Receipts(_unreachable_resolve), advertise(_FLAGS)]
async with connect(server, extensions=client_extensions) as client:
result = await client.call_tool("declared", {})
assert result.structured_content == {"result": [_FLAGS]}
+195
View File
@@ -0,0 +1,195 @@
"""Prompt interactions against MCPServer, driven through the public Client API."""
import pytest
from inline_snapshot import snapshot
from mcp_types import (
ErrorData,
GetPromptResult,
ListPromptsResult,
Prompt,
PromptArgument,
PromptMessage,
TextContent,
)
from mcp import MCPError
from mcp.server.mcpserver import MCPServer
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"))],
)
)
@@ -0,0 +1,183 @@
"""Resource interactions against MCPServer, driven through the public Client API."""
import pytest
from inline_snapshot import snapshot
from mcp_types import (
ErrorData,
ListResourcesResult,
ListResourceTemplatesResult,
ReadResourceResult,
Resource,
ResourceTemplate,
TextResourceContents,
)
from mcp import MCPError
from mcp.server.mcpserver import MCPServer
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 -32602 and the URI in data (SEP-2164)."""
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=-32602, message="Unknown resource: config://missing", data={"uri": "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 ResourceError that names only the URI, so the original
exception text is not leaked to the client. The wrapped exception surfaces as -32603 Internal error.
"""
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=-32603, message="Error reading resource res://boom", data={"uri": "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")])
)
@@ -0,0 +1,62 @@
"""Client.listen against MCPServer over the connect matrix (2026-07-28)."""
import anyio
import pytest
from mcp.client.subscriptions import ListenNotSupportedError, ResourceUpdated, ToolsListChanged
from mcp.server.mcpserver import Context, MCPServer
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
def _notebook() -> MCPServer:
mcp = MCPServer("notebook")
@mcp.tool()
async def touch_tools(ctx: Context) -> str:
await ctx.notify_tools_changed()
return "ok"
@mcp.tool()
async def edit_note(name: str, ctx: Context) -> str:
await ctx.notify_resource_updated(f"note://{name}")
return "saved"
return mcp
@requirement("subscriptions:listen:client:honored-surfacing")
@requirement("subscriptions:listen:client:iteration")
async def test_listen_surfaces_the_ack_and_iterates_typed_events(connect: Connect) -> None:
"""Entering waits for the ack (honored is set before any event); iteration yields
only the typed event kinds this stream opted in to."""
mcp = _notebook()
async with connect(mcp) as client:
with anyio.fail_after(10):
async with client.listen( # pragma: no branch
tools_list_changed=True, resource_subscriptions=["note://todo"]
) as sub:
assert sub.honored.tools_list_changed is True
assert sub.honored.resource_subscriptions == ["note://todo"]
await client.call_tool("edit_note", {"name": "journal"}) # unsubscribed URI: silent
await client.call_tool("edit_note", {"name": "todo"})
assert await anext(sub) == ResourceUpdated(uri="note://todo")
await client.call_tool("touch_tools", {})
assert await anext(sub) == ToolsListChanged()
@requirement("subscriptions:listen:client:era-guard")
async def test_listen_on_a_pre_2026_connection_raises_the_typed_steer(connect: Connect) -> None:
"""On 2025-era connections the guard fires before anything touches the wire, steering to the legacy verbs."""
mcp = _notebook()
async with connect(mcp) as client:
with anyio.fail_after(10):
# Entering is where the guard fires; __aenter__ directly avoids an unreachable with-body.
with pytest.raises(ListenNotSupportedError) as exc_info:
await client.listen(tools_list_changed=True).__aenter__()
assert exc_info.value.negotiated_version == client.session.protocol_version
assert "subscribe_resource" in str(exc_info.value)
+432
View 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 mcp_types import (
URL_ELICITATION_REQUIRED,
CallToolResult,
ElicitRequestURLParams,
ErrorData,
LoggingMessageNotification,
LoggingMessageNotificationParams,
TextContent,
)
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 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") # pyright: ignore[reportDeprecated]
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"))]
)