c54fe3b36a
The Option B footgun was: await elicit() looks like a suspension point but
is actually a re-entry point, so everything above it runs twice. Option H
fixes that by making it a REAL suspension point — the coroutine frame is
held in a ContinuationStore across MRTR rounds, keyed by request_state.
Handler code stays exactly as it was in the SSE era:
async def my_tool(ctx: LinearCtx, location: str) -> str:
audit_log(location) # runs exactly once
units = await ctx.elicit("Which units?", UnitsSchema)
return f"{location}: 22°{units.u}"
The wrapper linear_mrtr(my_tool, store=...) translates this into a standard
MRTR on_call_tool handler. Round 1 starts the coroutine; elicit() sends
IncompleteResult back through the wrapper and parks on a stream. Round 2's
retry wakes it with the answer. The coroutine continues from where it
stopped — no re-entry, no double-execution.
Trade-off: server holds the frame in memory between rounds. Client sees
pure MRTR (no SSE, independent requests), but server is stateful within
a single tool call. Horizontally-scaled deployments need sticky routing on
the request_state token. Same operational shape as Option A's SSE hold,
without the long-lived connection.
SDK pieces (src/mcp/server/experimental/mrtr/linear.py):
- LinearCtx with async elicit(message, PydanticSchema) -> instance
- ContinuationStore — owns the task group, TTL-based frame expiry
- linear_mrtr(handler, store=...) — the wrapper
- ElicitDeclined raised when user declines/cancels
7 E2E tests including the key assertion: side-effects above await fire
exactly once (the test measures audit_log count).
460 lines
19 KiB
Python
460 lines
19 KiB
Python
"""E2E tests for MRTR server-side primitives (SEP-2322).
|
|
|
|
Tests the ``mcp.server.experimental.mrtr`` module: ``MrtrCtx``,
|
|
``ToolBuilder``, ``input_response``, ``dispatch_by_version``.
|
|
|
|
The footgun test measures side-effect counts to prove F and G actually
|
|
hold the guard. The invariant test parametrises all handler shapes against
|
|
the same Client to prove the server's internal choice doesn't leak.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Awaitable, Callable
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from inline_snapshot import snapshot
|
|
from pydantic import BaseModel
|
|
|
|
from mcp import types
|
|
from mcp.client.client import Client
|
|
from mcp.client.context import ClientRequestContext
|
|
from mcp.server import Server, ServerRequestContext
|
|
from mcp.server.experimental.mrtr import (
|
|
ContinuationStore,
|
|
LinearCtx,
|
|
MrtrCtx,
|
|
ToolBuilder,
|
|
dispatch_by_version,
|
|
input_response,
|
|
linear_mrtr,
|
|
)
|
|
|
|
pytestmark = pytest.mark.anyio
|
|
|
|
|
|
# ─── Shared domain bits (mirror of examples/servers/mrtr-options) ────────────
|
|
|
|
|
|
UNITS_REQUEST = types.ElicitRequest(
|
|
params=types.ElicitRequestFormParams(
|
|
message="Which units?",
|
|
requested_schema={
|
|
"type": "object",
|
|
"properties": {"units": {"type": "string", "enum": ["metric", "imperial"]}},
|
|
"required": ["units"],
|
|
},
|
|
)
|
|
)
|
|
|
|
|
|
def lookup_weather(location: str, units: str) -> str:
|
|
temp = "22°C" if units == "metric" else "72°F"
|
|
return f"Weather in {location}: {temp}"
|
|
|
|
|
|
async def no_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> types.ListToolsResult:
|
|
return types.ListToolsResult(tools=[])
|
|
|
|
|
|
async def pick_metric(context: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult:
|
|
return types.ElicitResult(action="accept", content={"units": "metric"})
|
|
|
|
|
|
_audit: list[str] = []
|
|
|
|
|
|
def audit_log(where: str) -> None:
|
|
_audit.append(where)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_audit():
|
|
_audit.clear()
|
|
yield
|
|
|
|
|
|
MrtrHandler = Callable[
|
|
[ServerRequestContext, types.CallToolRequestParams], Awaitable[types.CallToolResult | types.IncompleteResult]
|
|
]
|
|
|
|
|
|
def make_server(handler: MrtrHandler) -> Server:
|
|
return Server("mrtr-test", on_call_tool=handler, on_list_tools=no_tools)
|
|
|
|
|
|
# ─── Handler shapes ──────────────────────────────────────────────────────────
|
|
|
|
|
|
async def option_e_degrade(
|
|
ctx: ServerRequestContext, params: types.CallToolRequestParams
|
|
) -> types.CallToolResult | types.IncompleteResult:
|
|
"""Option E — SDK default. MRTR-native; pre-MRTR gets default."""
|
|
location = (params.arguments or {}).get("location", "?")
|
|
prefs = input_response(params, "units")
|
|
if prefs is None:
|
|
return types.IncompleteResult(input_requests={"units": UNITS_REQUEST})
|
|
return types.CallToolResult(content=[types.TextContent(text=lookup_weather(location, prefs["units"]))])
|
|
|
|
|
|
async def option_f_ctx_once(
|
|
ctx: ServerRequestContext, params: types.CallToolRequestParams
|
|
) -> types.CallToolResult | types.IncompleteResult:
|
|
"""Option F — ctx.once idempotency guard."""
|
|
location = (params.arguments or {}).get("location", "?")
|
|
mrtr = MrtrCtx(params)
|
|
mrtr.once("audit", lambda: audit_log(f"F:{location}"))
|
|
prefs = input_response(params, "units")
|
|
if prefs is None:
|
|
return mrtr.incomplete({"units": UNITS_REQUEST})
|
|
return types.CallToolResult(content=[types.TextContent(text=lookup_weather(location, prefs["units"]))])
|
|
|
|
|
|
def ask_units(args: dict[str, Any], inputs: dict[str, Any]) -> types.IncompleteResult | dict[str, Any]:
|
|
resp = inputs.get("units")
|
|
if not resp or resp.get("action") != "accept":
|
|
return types.IncompleteResult(input_requests={"units": UNITS_REQUEST})
|
|
return {"units": resp["content"]["units"]}
|
|
|
|
|
|
def fetch_weather(args: dict[str, Any], collected: dict[str, Any]) -> types.CallToolResult:
|
|
location = (args or {}).get("location", "?")
|
|
audit_log(f"G:{location}")
|
|
return types.CallToolResult(content=[types.TextContent(text=lookup_weather(location, collected["units"]))])
|
|
|
|
|
|
option_g_tool_builder = (
|
|
ToolBuilder[dict[str, Any]]().incomplete_step("ask_units", ask_units).end_step(fetch_weather).build()
|
|
)
|
|
|
|
|
|
async def option_e_with_naive_audit(
|
|
ctx: ServerRequestContext, params: types.CallToolRequestParams
|
|
) -> types.CallToolResult | types.IncompleteResult:
|
|
"""Option E with a naive side-effect above the guard — the footgun."""
|
|
location = (params.arguments or {}).get("location", "?")
|
|
audit_log(f"naive:{location}") # runs on EVERY round
|
|
prefs = input_response(params, "units")
|
|
if prefs is None:
|
|
return types.IncompleteResult(input_requests={"units": UNITS_REQUEST})
|
|
return types.CallToolResult(content=[types.TextContent(text=lookup_weather(location, prefs["units"]))])
|
|
|
|
|
|
# ─── The invariant: client can't tell ────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"handler",
|
|
[option_e_degrade, option_f_ctx_once, option_g_tool_builder],
|
|
ids=["E-degrade", "F-ctx_once", "G-tool_builder"],
|
|
)
|
|
async def test_mrtr_wire_invariant(handler: MrtrHandler):
|
|
"""All MRTR handler shapes produce identical wire behaviour.
|
|
|
|
The server's internal choice (guard-first, ctx.once, ToolBuilder) doesn't
|
|
leak to the client. Same Client, same callback, same result. This is the
|
|
argument against per-feature ``-mrtr`` capability flags.
|
|
"""
|
|
async with Client(make_server(handler), elicitation_callback=pick_metric) as client:
|
|
result = await client.call_tool("weather", {"location": "Tokyo"})
|
|
assert isinstance(result, types.CallToolResult)
|
|
assert result.content[0] == types.TextContent(text="Weather in Tokyo: 22°C")
|
|
|
|
|
|
# ─── The footgun: side-effect counts ─────────────────────────────────────────
|
|
|
|
|
|
async def test_mrtr_naive_handler_double_executes_side_effect():
|
|
"""The footgun, measured. Naive MRTR handler fires audit_log twice."""
|
|
async with Client(make_server(option_e_with_naive_audit), elicitation_callback=pick_metric) as client:
|
|
await client.call_tool("weather", {"location": "Tokyo"})
|
|
assert _audit == snapshot(["naive:Tokyo", "naive:Tokyo"])
|
|
|
|
|
|
async def test_mrtr_ctx_once_holds_side_effect():
|
|
"""Option F: ctx.once guard holds the side-effect to one across retry."""
|
|
async with Client(make_server(option_f_ctx_once), elicitation_callback=pick_metric) as client:
|
|
await client.call_tool("weather", {"location": "Tokyo"})
|
|
assert _audit == snapshot(["F:Tokyo"])
|
|
|
|
|
|
async def test_mrtr_tool_builder_end_step_runs_once():
|
|
"""Option G: end_step runs exactly once regardless of round count."""
|
|
async with Client(make_server(option_g_tool_builder), elicitation_callback=pick_metric) as client:
|
|
await client.call_tool("weather", {"location": "Tokyo"})
|
|
assert _audit == snapshot(["G:Tokyo"])
|
|
|
|
|
|
# ─── ToolBuilder edge cases ──────────────────────────────────────────────────
|
|
|
|
|
|
def test_tool_builder_requires_end_step():
|
|
with pytest.raises(ValueError, match="end_step is required"):
|
|
ToolBuilder[dict[str, Any]]().incomplete_step("x", ask_units).build()
|
|
|
|
|
|
def test_tool_builder_rejects_duplicate_step_names():
|
|
with pytest.raises(ValueError, match="duplicate step names"):
|
|
ToolBuilder[dict[str, Any]]().incomplete_step("x", ask_units).incomplete_step("x", ask_units).end_step(
|
|
fetch_weather
|
|
).build()
|
|
|
|
|
|
async def test_tool_builder_multi_step_accumulates():
|
|
"""Two incomplete_steps before end_step — collected dict merges."""
|
|
|
|
def ask_lang(args: dict[str, Any], inputs: dict[str, Any]) -> types.IncompleteResult | dict[str, Any]:
|
|
resp = inputs.get("lang")
|
|
if not resp or resp.get("action") != "accept":
|
|
return types.IncompleteResult(
|
|
input_requests={
|
|
"lang": types.ElicitRequest(
|
|
params=types.ElicitRequestFormParams(message="Lang?", requested_schema={})
|
|
)
|
|
}
|
|
)
|
|
return {"lang": resp["content"]["lang"]}
|
|
|
|
def finish(args: dict[str, Any], collected: dict[str, Any]) -> types.CallToolResult:
|
|
return types.CallToolResult(content=[types.TextContent(text=f"{collected['units']}/{collected['lang']}")])
|
|
|
|
handler = (
|
|
ToolBuilder[dict[str, Any]]()
|
|
.incomplete_step("ask_units", ask_units)
|
|
.incomplete_step("ask_lang", ask_lang)
|
|
.end_step(finish)
|
|
.build()
|
|
)
|
|
|
|
answers = {"Which units?": {"units": "metric"}, "Lang?": {"lang": "en"}}
|
|
|
|
async def elicitation_cb(context: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult:
|
|
assert isinstance(params, types.ElicitRequestFormParams)
|
|
return types.ElicitResult(action="accept", content=dict(answers[params.message]))
|
|
|
|
async with Client(make_server(handler), elicitation_callback=elicitation_cb) as client:
|
|
result = await client.call_tool("multi", {})
|
|
assert result == snapshot(types.CallToolResult(content=[types.TextContent(text="metric/en")]))
|
|
|
|
|
|
# ─── MrtrCtx edge cases ──────────────────────────────────────────────────────
|
|
|
|
|
|
async def test_mrtr_ctx_once_persists_across_multiple_rounds():
|
|
"""once() guard survives 3+ rounds — executed-keys round-trip through request_state."""
|
|
|
|
async def handler(
|
|
ctx: ServerRequestContext, params: types.CallToolRequestParams
|
|
) -> types.CallToolResult | types.IncompleteResult:
|
|
mrtr = MrtrCtx(params)
|
|
mrtr.once("init", lambda: audit_log("init"))
|
|
|
|
# Step progression tracked via executed keys, not raw input_responses
|
|
# (which only carries the latest round's answers per SEP).
|
|
if not mrtr.has_run("got_a"):
|
|
if not input_response(params, "a"):
|
|
return mrtr.incomplete({"a": UNITS_REQUEST})
|
|
mrtr.once("got_a", lambda: audit_log("after_a"))
|
|
|
|
if not input_response(params, "b"):
|
|
return mrtr.incomplete({"b": UNITS_REQUEST})
|
|
mrtr.once("got_b", lambda: audit_log("after_b"))
|
|
return types.CallToolResult(content=[types.TextContent(text="done")])
|
|
|
|
async def elicitation_cb(context: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult:
|
|
return types.ElicitResult(action="accept", content={"units": "metric"})
|
|
|
|
async with Client(make_server(handler), elicitation_callback=elicitation_cb) as client:
|
|
await client.call_tool("multi", {})
|
|
|
|
assert _audit == snapshot(["init", "after_a", "after_b"])
|
|
|
|
|
|
# ─── input_response helper ───────────────────────────────────────────────────
|
|
|
|
|
|
def test_input_response_returns_none_on_missing():
|
|
params = types.CallToolRequestParams(name="x")
|
|
assert input_response(params, "key") is None
|
|
|
|
|
|
def test_input_response_returns_none_on_decline():
|
|
params = types.CallToolRequestParams(name="x", input_responses={"key": {"action": "decline"}})
|
|
assert input_response(params, "key") is None
|
|
|
|
|
|
def test_input_response_returns_content_on_accept():
|
|
params = types.CallToolRequestParams(name="x", input_responses={"key": {"action": "accept", "content": {"v": 1}}})
|
|
assert input_response(params, "key") == {"v": 1}
|
|
|
|
|
|
# ─── dispatch_by_version ─────────────────────────────────────────────────────
|
|
|
|
|
|
async def _mrtr_path(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult:
|
|
return types.CallToolResult(content=[types.TextContent(text="mrtr")])
|
|
|
|
|
|
async def _sse_path(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult:
|
|
return types.CallToolResult(content=[types.TextContent(text="sse")])
|
|
|
|
|
|
async def test_dispatch_by_version_routes_to_mrtr_when_at_or_above():
|
|
"""Negotiated version >= min → MRTR handler."""
|
|
handler = dispatch_by_version(mrtr=_mrtr_path, sse=_sse_path, min_mrtr_version=types.LATEST_PROTOCOL_VERSION)
|
|
async with Client(make_server(handler)) as client:
|
|
result = await client.call_tool("x", {})
|
|
assert result == snapshot(types.CallToolResult(content=[types.TextContent(text="mrtr")]))
|
|
|
|
|
|
async def test_dispatch_by_version_routes_to_sse_when_below():
|
|
"""Negotiated version < min → SSE handler."""
|
|
handler = dispatch_by_version(mrtr=_mrtr_path, sse=_sse_path, min_mrtr_version="9999-01-01")
|
|
async with Client(make_server(handler)) as client:
|
|
result = await client.call_tool("x", {})
|
|
assert result == snapshot(types.CallToolResult(content=[types.TextContent(text="sse")]))
|
|
|
|
|
|
# ─── Option H: linear_mrtr — continuation-based, genuine suspension ──────────
|
|
|
|
|
|
class Units(BaseModel):
|
|
units: str
|
|
|
|
|
|
async def test_linear_mrtr_side_effects_run_exactly_once():
|
|
"""The Option B footgun, fixed: ``await ctx.elicit()`` is a real suspension point.
|
|
|
|
Side-effects above and below the await fire exactly once — the coroutine
|
|
frame is held in the ContinuationStore across MRTR rounds, so there is
|
|
no re-entry.
|
|
"""
|
|
|
|
async def weather(ctx: LinearCtx, args: dict[str, Any]) -> str:
|
|
location = args["location"]
|
|
audit_log(f"before:{location}") # would fire twice under Option B
|
|
prefs = await ctx.elicit("Which units?", Units)
|
|
audit_log(f"after:{prefs.units}")
|
|
return lookup_weather(location, prefs.units)
|
|
|
|
store = ContinuationStore()
|
|
server = make_server(linear_mrtr(weather, store=store))
|
|
|
|
async with store:
|
|
async with Client(server, elicitation_callback=pick_metric) as client:
|
|
result = await client.call_tool("weather", {"location": "Tokyo"})
|
|
assert result == snapshot(types.CallToolResult(content=[types.TextContent(text="Weather in Tokyo: 22°C")]))
|
|
|
|
assert _audit == snapshot(["before:Tokyo", "after:metric"])
|
|
|
|
|
|
async def test_linear_mrtr_multiple_elicits():
|
|
"""Two sequential ``await ctx.elicit()`` calls — three MRTR rounds."""
|
|
|
|
class Lang(BaseModel):
|
|
lang: str
|
|
|
|
async def handler(ctx: LinearCtx, args: dict[str, Any]) -> str:
|
|
audit_log("start")
|
|
u = await ctx.elicit("Which units?", Units)
|
|
audit_log(f"got units={u.units}")
|
|
lang = await ctx.elicit("Which language?", Lang)
|
|
audit_log(f"got lang={lang.lang}")
|
|
return f"{u.units}/{lang.lang}"
|
|
|
|
store = ContinuationStore()
|
|
server = make_server(linear_mrtr(handler, store=store))
|
|
|
|
answers = {"Which units?": {"units": "metric"}, "Which language?": {"lang": "en"}}
|
|
|
|
async def elicitation_cb(context: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult:
|
|
assert isinstance(params, types.ElicitRequestFormParams)
|
|
return types.ElicitResult(action="accept", content=dict(answers[params.message]))
|
|
|
|
async with store:
|
|
async with Client(server, elicitation_callback=elicitation_cb) as client:
|
|
result = await client.call_tool("multi", {})
|
|
assert result == snapshot(types.CallToolResult(content=[types.TextContent(text="metric/en")]))
|
|
|
|
assert _audit == snapshot(["start", "got units=metric", "got lang=en"])
|
|
|
|
|
|
async def test_linear_mrtr_elicit_declined_propagates():
|
|
"""User declines → handler sees ElicitDeclined, wrapper returns a cancelled result."""
|
|
|
|
async def handler(ctx: LinearCtx, args: dict[str, Any]) -> str:
|
|
await ctx.elicit("Confirm?", Units)
|
|
return "never reached" # pragma: no cover
|
|
|
|
store = ContinuationStore()
|
|
server = make_server(linear_mrtr(handler, store=store))
|
|
|
|
async def decline_cb(context: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult:
|
|
return types.ElicitResult(action="decline")
|
|
|
|
async with store:
|
|
async with Client(server, elicitation_callback=decline_cb) as client:
|
|
result = await client.call_tool("confirm", {})
|
|
assert result == snapshot(types.CallToolResult(content=[types.TextContent(text="Cancelled (decline).")]))
|
|
|
|
|
|
async def test_linear_mrtr_handler_exception_surfaces():
|
|
"""Exception in handler → surfaced as is_error result."""
|
|
|
|
async def handler(ctx: LinearCtx, args: dict[str, Any]) -> str:
|
|
raise ValueError("boom")
|
|
|
|
store = ContinuationStore()
|
|
server = make_server(linear_mrtr(handler, store=store))
|
|
|
|
async with store:
|
|
async with Client(server) as client:
|
|
result = await client.call_tool("fail", {})
|
|
assert result == snapshot(types.CallToolResult(content=[types.TextContent(text="boom")], is_error=True))
|
|
|
|
|
|
async def test_linear_mrtr_unknown_token_errors():
|
|
"""Retry with a request_state that isn't in the store → clear error."""
|
|
|
|
async def handler(ctx: LinearCtx, args: dict[str, Any]) -> str: # pragma: no cover
|
|
return "x"
|
|
|
|
store = ContinuationStore()
|
|
wrapped = linear_mrtr(handler, store=store)
|
|
|
|
async with store:
|
|
params = types.CallToolRequestParams(name="x", request_state="bogus")
|
|
result = await wrapped(None, params)
|
|
assert isinstance(result, types.CallToolResult)
|
|
assert result.is_error
|
|
assert "expired or unknown" in result.content[0].text # type: ignore[union-attr]
|
|
|
|
|
|
async def test_linear_mrtr_handler_can_return_call_tool_result():
|
|
"""Handler returning CallToolResult directly (not str shorthand)."""
|
|
|
|
async def handler(ctx: LinearCtx, args: dict[str, Any]) -> types.CallToolResult:
|
|
return types.CallToolResult(content=[types.TextContent(text="direct")])
|
|
|
|
store = ContinuationStore()
|
|
server = make_server(linear_mrtr(handler, store=store))
|
|
|
|
async with store:
|
|
async with Client(server) as client:
|
|
result = await client.call_tool("direct", {})
|
|
assert result == snapshot(types.CallToolResult(content=[types.TextContent(text="direct")]))
|
|
|
|
|
|
async def test_linear_mrtr_store_not_entered_raises():
|
|
"""Calling without entering the store → clear RuntimeError."""
|
|
|
|
async def handler(ctx: LinearCtx, args: dict[str, Any]) -> str: # pragma: no cover
|
|
return "x"
|
|
|
|
store = ContinuationStore()
|
|
wrapped = linear_mrtr(handler, store=store)
|
|
|
|
with pytest.raises(RuntimeError, match="ContinuationStore not entered"):
|
|
await wrapped(None, types.CallToolRequestParams(name="x"))
|