Files
Max Isbey 98bd125ddd Serve the 2026-07-28 protocol over stdio by deciding the era from the opening message
The 2026-07-28 protocol did not work over stdio: subscriptions/listen
was hard-refused, a legacy initialize arriving during an in-flight
modern request was accepted and re-locked the connection, and a peer
cancel produced a trailing "Request cancelled" frame. All three share
one root cause: the connection's era was derived from which requests
had completed instead of being decided once, in wire order, from how
the client opened the connection.

Replace serve_dual_era_loop and serve_loop with a single serve_stream
driver that decides the era synchronously in the dispatcher's read
loop, before the request body is spawned. initialize (or any
envelope-less request) opens the legacy era, an enveloped request opens
the modern era, server/discover is answered without pinning, and a
stray leading notification opens nothing. A conflicting era claim on a
committed connection is refused (-32022 or -32600) rather than
silently switching.

Cancel silence is structural: each request answers through a one-shot
channel whose write target becomes a powerless void on a peer cancel,
so there is no cancelled-check at any write site. The generic JSON-RPC
dispatcher loses the code-0 cancel frame, the code-0 str(exc)
catch-all, and the inline_methods knob, and documents that handlers
are invoked synchronously in receive order with the returned awaitable
as the body.

Add a Posture enum (DUAL default, LEGACY_ONLY, MODERN_ONLY) on the
Server and MCPServer constructors, honoured by the stream driver and
the streamable-HTTP manager alike. Server.run(read, write) now stands
alone, Server.lifespan() is a bound context manager, and
serve_listener / newline_json_transport / close_subscriptions() give a
straightforward path for custom transports. See docs/migration.md for
the full list of observable changes.
2026-07-23 13:18:49 +00:00

104 lines
4.6 KiB
Python

"""`docs/servers/prompts.md`: every claim the page makes, proved against the real SDK."""
import traceback
import pytest
from inline_snapshot import snapshot
from mcp_types import PromptArgument, PromptMessage, TextContent
from docs_src.prompts import tutorial001, tutorial002, tutorial003
from mcp import Client, MCPError
from tests.docs_src._helpers import strip_server_info
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_function_becomes_the_prompt() -> None:
"""tutorial001: the name, the docstring and the parameters are the whole `prompts/list` entry."""
async with Client(tutorial001.mcp) as client:
(prompt,) = (await client.list_prompts()).prompts
assert prompt.model_dump(mode="json", by_alias=True, exclude_none=True) == snapshot(
{
"name": "review_code",
"description": "Review a piece of code.",
"arguments": [{"name": "code", "required": True}],
}
)
async def test_returned_string_becomes_one_user_message() -> None:
"""tutorial001: a `str` return value is rendered as a single `user` message."""
async with Client(tutorial001.mcp) as client:
result = await client.get_prompt("review_code", {"code": "def add(a, b): return a + b"})
result = strip_server_info(result, tutorial001.mcp)
assert result.model_dump(mode="json", by_alias=True, exclude_none=True) == snapshot(
{
"description": "Review a piece of code.",
"messages": [
{
"role": "user",
"content": {
"type": "text",
"text": "Please review this code:\n\ndef add(a, b): return a + b",
},
}
],
"resultType": "complete",
}
)
async def test_missing_required_argument_is_a_protocol_error() -> None:
"""tutorial001: omitting a required argument fails the request itself. There is no error result."""
async with Client(tutorial001.mcp) as client:
with pytest.raises(MCPError) as exc_info:
await client.get_prompt("review_code")
assert exc_info.value.code == -32602
assert exc_info.value.message == "Missing required arguments: {'code'}"
# The line a traceback prints, exactly as the page quotes it.
assert traceback.format_exception_only(exc_info.value) == snapshot(
["mcp.shared.exceptions.MCPError: Missing required arguments: {'code'}\n"]
)
async def test_message_list_becomes_a_multi_turn_template() -> None:
"""tutorial002: a list of `UserMessage` / `AssistantMessage` renders in order, roles intact."""
async with Client(tutorial002.mcp) as client:
assert [p.name for p in (await client.list_prompts()).prompts] == ["review_code", "debug_error"]
result = await client.get_prompt("debug_error", {"error": "TypeError: 'int' object is not iterable"})
assert result.messages == [
PromptMessage(role="user", content=TextContent(type="text", text="I'm seeing this error:")),
PromptMessage(
role="user",
content=TextContent(type="text", text="TypeError: 'int' object is not iterable"),
),
PromptMessage(
role="assistant",
content=TextContent(type="text", text="I'll help debug that. What have you tried so far?"),
),
]
async def test_title_and_argument_descriptions() -> None:
"""tutorial003: `title=` and `Field(description=...)` land in the `prompts/list` entry."""
async with Client(tutorial003.mcp) as client:
(prompt,) = (await client.list_prompts()).prompts
assert prompt.title == "Code review"
assert prompt.arguments == [
PromptArgument(name="code", description="The code to review.", required=True),
PromptArgument(name="language", description="The language the code is written in.", required=False),
]
async def test_default_value_makes_the_argument_optional() -> None:
"""tutorial003: a parameter with a default can be omitted and the default is used in the render."""
async with Client(tutorial003.mcp) as client:
result = await client.get_prompt("review_code", {"code": "x = 1"})
assert result.messages == [
PromptMessage(
role="user",
content=TextContent(type="text", text="Please review this python code:\n\nx = 1"),
)
]