chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,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
|
||||
@@ -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]}
|
||||
@@ -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)
|
||||
@@ -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"))]
|
||||
)
|
||||
Reference in New Issue
Block a user