|
| 1 | +# MRTR handler-shape options (SEP-2322) |
| 2 | + |
| 3 | +Python-SDK counterpart to [typescript-sdk#1701]. Seven ways to write the same |
| 4 | +weather-lookup tool, so the diff between files is the argument. |
| 5 | + |
| 6 | +Unlike the TS demos, the lowlevel plumbing here is **real** — each option is |
| 7 | +an actual `mcp.server.Server` that round-trips `IncompleteResult` through the |
| 8 | +wire protocol. The invariant test at the bottom asserts they all produce |
| 9 | +identical client-observed behaviour. |
| 10 | + |
| 11 | +[typescript-sdk#1701]: https://github.com/modelcontextprotocol/typescript-sdk/pull/1701 |
| 12 | + |
| 13 | +## The quadrant |
| 14 | + |
| 15 | +| Server infra | Pre-MRTR client | MRTR client | |
| 16 | +| ------------------------------- | --------------------------------- | ----------- | |
| 17 | +| Can hold SSE | E by default; A/C/D if you opt in | MRTR | |
| 18 | +| MRTR-only (horizontally scaled) | E by necessity | MRTR | |
| 19 | + |
| 20 | +Both rows *work* for old clients — version negotiation succeeds, |
| 21 | +`tools/list` is complete, tools that don't elicit are unaffected. Only |
| 22 | +elicitation inside a tool is unavailable. Bottom-left isn't "unresolvable"; |
| 23 | +it's "E is the only option." Top-left is "E, unless you choose to carry SSE |
| 24 | +infra." The rows collapse for E, which is why it's the SDK default. |
| 25 | + |
| 26 | +## Options |
| 27 | + |
| 28 | +| | Author writes | SDK does | Hidden re-entry | Old client gets | |
| 29 | +| ------------------------------ | ------------------------------- | -------------------------------- | --------------- | --------------------------------- | |
| 30 | +| [E](mrtr_options/option_e_degrade.py) | MRTR-native only | Nothing | No | Result w/ default, or error | |
| 31 | +| [A](mrtr_options/option_a_sse_shim.py) | MRTR-native only | Retry-loop over SSE | Yes, safe | Full elicitation | |
| 32 | +| [B](mrtr_options/option_b_await_shim.py) | `await elicit()` | Exception → `IncompleteResult` | **Yes, unsafe** | Full elicitation | |
| 33 | +| [C](mrtr_options/option_c_version_branch.py) | One handler, `if version` branch | Version accessor | No | Full elicitation | |
| 34 | +| [D](mrtr_options/option_d_dual_handler.py) | Two handlers | Picks by version | No | Full elicitation | |
| 35 | +| [F](mrtr_options/option_f_ctx_once.py) | MRTR-native + `ctx.once` wraps | `once()` guard in request_state | No | (same as E) | |
| 36 | +| [G](mrtr_options/option_g_tool_builder.py) | Step functions + `.build()` | Step-tracking in request_state | No | (same as E) | |
| 37 | + |
| 38 | +"Hidden re-entry" = the handler function is invoked more than once for a |
| 39 | +single logical tool call, and the author can't tell from the source text. |
| 40 | + |
| 41 | +**A is safe** because MRTR-native code has the re-entry guard (`if not |
| 42 | +prefs: return IncompleteResult(...)`) visible in source even though the |
| 43 | +*loop* is hidden. |
| 44 | + |
| 45 | +**B is unsafe** because `await elicit()` looks like a suspension point but |
| 46 | +is actually a re-entry point on MRTR sessions — see the `audit_log` |
| 47 | +landmine in that file. |
| 48 | + |
| 49 | +## Footgun prevention (F, G) |
| 50 | + |
| 51 | +A–E are about the dual-path axis (old client vs new). F and G address a |
| 52 | +different axis: even in a pure-MRTR world, the naive handler shape has a |
| 53 | +footgun. Code above the `if not prefs` guard runs on every retry. If that |
| 54 | +code is a DB write or HTTP POST, it executes N times for N-round |
| 55 | +elicitation. Nothing *enforces* putting side-effects below the guard — |
| 56 | +safety depends on the developer knowing the convention. The analogy from |
| 57 | +SDK-WG review: the naive MRTR handler is de-facto GOTO. |
| 58 | + |
| 59 | +**F (`MrtrCtx.once`)** keeps the monolithic handler but wraps side-effects |
| 60 | +in an idempotency guard. `ctx.once("audit", lambda: audit_log(...))` checks |
| 61 | +`request_state` — if the key is marked executed, skip. Opt-in: an unwrapped |
| 62 | +mutation still fires twice. The footgun is made *visually distinct*, which |
| 63 | +is reviewable. |
| 64 | + |
| 65 | +**G (`ToolBuilder`)** decomposes the handler into named step functions. |
| 66 | +`incomplete_step` may return `IncompleteResult` or data; `end_step` receives |
| 67 | +everything and runs exactly once. There is no "above the guard" zone because |
| 68 | +there is no guard — the SDK's step-tracking is the guard. Side-effects go in |
| 69 | +`end_step`, structurally unreachable until all elicitations complete. |
| 70 | + |
| 71 | +Both depend on `request_state` integrity. The demos use plain base64-JSON; |
| 72 | +a real SDK MUST HMAC-sign the blob, or the client can forge step-done |
| 73 | +markers and skip the guards. Per-session key derived from `initialize` keeps |
| 74 | +it stateless. Without signing, the safety story is advisory. |
| 75 | + |
| 76 | +## Trade-offs |
| 77 | + |
| 78 | +**E is the SDK default.** A horizontally-scaled server gets E for free — |
| 79 | +it's the only thing that works on that infra. A server that can hold SSE |
| 80 | +also gets E by default, and opts into A/C/D only if serving old-client |
| 81 | +elicitation is worth the extra infra dependency. |
| 82 | + |
| 83 | +**A vs E** is the core tension. Same author-facing code (MRTR-native), the |
| 84 | +only difference is whether old clients get elicitation. A requires shipping |
| 85 | +`sse_retry_shim`; E requires nothing. A also carries a deployment-time |
| 86 | +hazard E doesn't: the shim calls real SSE under the hood, so on MRTR-only |
| 87 | +infra it fails at runtime when an old client connects — a constraint that |
| 88 | +lives nowhere near the tool code. |
| 89 | + |
| 90 | +**B** is zero-migration but breaks silently for anything non-idempotent |
| 91 | +above the await. Not a ship target. |
| 92 | + |
| 93 | +**C vs D** is factoring: one function with a branch vs two functions with a |
| 94 | +dispatcher. Both put the dual-path burden on the tool author. |
| 95 | + |
| 96 | +**F vs G** is the footgun-prevention trade. F is minimal — one line per |
| 97 | +side-effect, composes with any handler shape. G is structural — |
| 98 | +double-execution impossible for `end_step`, but costs two function defs |
| 99 | +per tool. Likely SDK answer: ship F as a primitive on the context, ship G |
| 100 | +as an opt-in builder, recommend G for multi-round tools and F for |
| 101 | +single-question tools. |
| 102 | + |
| 103 | +## The invariant test |
| 104 | + |
| 105 | +`tests/server/experimental/test_mrtr_options.py` parametrises all seven |
| 106 | +servers against the same `Client` + `elicitation_callback`, asserting |
| 107 | +identical output. The footgun test measures `audit_count` to prove F and G |
| 108 | +hold the side-effect to one. |
| 109 | + |
| 110 | +## Not in scope |
| 111 | + |
| 112 | +- Persistent/Tasks workflow — `ServerTaskContext` already does |
| 113 | + `input_required`; MRTR integration is a separate PR |
| 114 | +- `mrtrOnly` client flag — trivial to add, not demoed |
| 115 | +- requestState HMAC signing — called out in code comments |
0 commit comments