Skip to content

Commit c54fe3b

Browse files
committed
feat(mrtr): linear continuation-based handler — Option H
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).
1 parent 1acd0ce commit c54fe3b

File tree

5 files changed

+518
-10
lines changed

5 files changed

+518
-10
lines changed

examples/servers/mrtr-options/README.md

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,16 @@ infra." The rows collapse for E, which is why it's the SDK default.
4545

4646
## Options
4747

48-
| | Author writes | SDK does | Hidden re-entry | Old client gets |
49-
| ------------------------------ | ------------------------------- | -------------------------------- | --------------- | --------------------------------- |
50-
| [E](mrtr_options/option_e_degrade.py) | MRTR-native only | Nothing | No | Result w/ default, or error |
51-
| [A](mrtr_options/option_a_sse_shim.py) | MRTR-native only | Retry-loop over SSE | Yes, safe | Full elicitation |
52-
| [B](mrtr_options/option_b_await_shim.py) | `await elicit()` | Exception → `IncompleteResult` | **Yes, unsafe** | Full elicitation |
53-
| [C](mrtr_options/option_c_version_branch.py) | One handler, `if version` branch | Version accessor | No | Full elicitation |
54-
| [D](mrtr_options/option_d_dual_handler.py) | Two handlers | Picks by version | No | Full elicitation |
55-
| [F](mrtr_options/option_f_ctx_once.py) | MRTR-native + `ctx.once` wraps | `once()` guard in request_state | No | (same as E) |
56-
| [G](mrtr_options/option_g_tool_builder.py) | Step functions + `.build()` | Step-tracking in request_state | No | (same as E) |
48+
| | Author writes | SDK does | Hidden re-entry | Server state | Old client gets |
49+
| ------------------------------ | ------------------------------- | -------------------------------- | --------------- | -------------------- | --------------------------------- |
50+
| [E](mrtr_options/option_e_degrade.py) | MRTR-native only | Nothing | No | None | Result w/ default, or error |
51+
| [A](mrtr_options/option_a_sse_shim.py) | MRTR-native only | Retry-loop over SSE | Yes, safe | SSE connection | Full elicitation |
52+
| [B](mrtr_options/option_b_await_shim.py) | `await elicit()` | Exception → `IncompleteResult` | **Yes, unsafe** | None | Full elicitation |
53+
| [C](mrtr_options/option_c_version_branch.py) | One handler, `if version` branch | Version accessor | No | SSE (old-client arm) | Full elicitation |
54+
| [D](mrtr_options/option_d_dual_handler.py) | Two handlers | Picks by version | No | SSE (old-client arm) | Full elicitation |
55+
| [F](mrtr_options/option_f_ctx_once.py) | MRTR-native + `ctx.once` wraps | `once()` guard in request_state | No | None | (same as E) |
56+
| [G](mrtr_options/option_g_tool_builder.py) | Step functions + `.build()` | Step-tracking in request_state | No | None | (same as E) |
57+
| [H](mrtr_options/option_h_linear.py) | `await ctx.elicit()` (linear) | Holds coroutine frame in memory | No | Coroutine frame | (same as E) |
5758

5859
"Hidden re-entry" = the handler function is invoked more than once for a
5960
single logical tool call, and the author can't tell from the source text.
@@ -120,6 +121,17 @@ per tool. Likely SDK answer: ship F as a primitive on the context, ship G
120121
as an opt-in builder, recommend G for multi-round tools and F for
121122
single-question tools.
122123

124+
**H (linear continuation)** is the Option B footgun, *fixed*. Handler code
125+
reads exactly like the SSE era — `await ctx.elicit()` is a genuine
126+
suspension point, side-effects above it fire once — because the coroutine
127+
frame is held in memory across rounds. The trade: server is stateful
128+
*within* a single tool call (frame keyed by `request_state`), so
129+
horizontally-scaled deployments need sticky routing on the token. Same
130+
operational shape as A's SSE hold but without the long-lived connection.
131+
Use for migrating existing SSE-era tools without rewriting, or when the
132+
linear style is genuinely clearer than guard-first. Don't use if you need
133+
true statelessness — E/F/G encode everything in `request_state` itself.
134+
123135
## The invariant test
124136

125137
`tests/server/experimental/test_mrtr_options.py` parametrises all seven
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Option H: continuation-based linear MRTR. ``await ctx.elicit()`` is genuine.
2+
3+
The Option B footgun was: ``await elicit()`` *looks* like a suspension point
4+
but is actually a re-entry point, so everything above it runs twice. This
5+
fixes that by making it a *real* suspension point — the coroutine frame is
6+
held in a ``ContinuationStore`` across MRTR rounds, keyed by
7+
``request_state``.
8+
9+
Handler code stays exactly as it was in the SSE era. Side-effects above
10+
the await fire once because the function never restarts — it resumes.
11+
12+
Trade-off: the server holds the frame in memory between rounds. Client
13+
still sees pure MRTR (no SSE), but the server is stateful *within* a
14+
single tool call. Horizontally-scaled deployments need sticky routing on
15+
the ``request_state`` token. Same operational shape as Option A's SSE
16+
hold, without the long-lived connection.
17+
18+
When to use: migrating existing SSE-era tools to MRTR wire protocol
19+
without rewriting the handler, or when the linear style is genuinely
20+
clearer than guard-first (complex branching, many rounds).
21+
22+
When not to: if you need true statelessness across server instances.
23+
Use E/F/G — they encode everything the server needs in ``request_state``
24+
itself.
25+
"""
26+
27+
from __future__ import annotations
28+
29+
from typing import Any
30+
31+
from pydantic import BaseModel
32+
33+
from mcp.server.experimental.mrtr import ContinuationStore, LinearCtx, linear_mrtr
34+
35+
from ._shared import audit_log, build_server, lookup_weather
36+
37+
38+
class UnitsPref(BaseModel):
39+
units: str
40+
41+
42+
# ───────────────────────────────────────────────────────────────────────────
43+
# This is what the tool author writes. Linear, front-to-back, no re-entry
44+
# contract to reason about. The ``audit_log`` above the await fires
45+
# exactly once — the await is a real suspension point.
46+
# ───────────────────────────────────────────────────────────────────────────
47+
48+
49+
async def weather(ctx: LinearCtx, args: dict[str, Any]) -> str:
50+
location = args["location"]
51+
audit_log(location) # runs once — unlike Option B
52+
prefs = await ctx.elicit("Which units?", UnitsPref)
53+
return lookup_weather(location, prefs.units)
54+
55+
56+
# ───────────────────────────────────────────────────────────────────────────
57+
# Registration. The store must be entered as an async context manager
58+
# around the server's run loop — it owns the task group that keeps the
59+
# suspended coroutines alive.
60+
# ───────────────────────────────────────────────────────────────────────────
61+
62+
store = ContinuationStore()
63+
server = build_server("mrtr-option-h", on_call_tool=linear_mrtr(weather, store=store))

src/mcp/server/experimental/mrtr/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,18 @@
3030
from mcp.server.experimental.mrtr.builder import EndStep, IncompleteStep, ToolBuilder
3131
from mcp.server.experimental.mrtr.compat import MrtrHandler, dispatch_by_version, sse_retry_shim
3232
from mcp.server.experimental.mrtr.context import MrtrCtx
33+
from mcp.server.experimental.mrtr.linear import ContinuationStore, ElicitDeclined, LinearCtx, linear_mrtr
3334

3435
__all__ = [
3536
"MrtrCtx",
3637
"ToolBuilder",
3738
"IncompleteStep",
3839
"EndStep",
3940
"MrtrHandler",
41+
"LinearCtx",
42+
"ContinuationStore",
43+
"ElicitDeclined",
44+
"linear_mrtr",
4045
"input_response",
4146
"encode_state",
4247
"decode_state",

0 commit comments

Comments
 (0)