Skip to content

Commit 4facab7

Browse files
committed
examples(mrtr): add basic and multi-round lowlevel reference examples
Two standalone reference examples before the comparison deck: - basic.py: the simple-tool equivalent for MRTR. One IncompleteResult, one retry. Comments walk through the two moves every MRTR handler makes: check input_responses, return IncompleteResult if missing. Runnable end-to-end against the in-memory Client. - basic_multiround.py: the ADO-rules SEP example translated. Two cascading elicitation rounds with request_state carrying accumulated context so any server instance can handle any round. Shows the key gotcha: input_responses carries only the latest round's answers, not accumulated — anything that must survive goes in request_state.
1 parent 29cb1ba commit 4facab7

File tree

3 files changed

+315
-0
lines changed

3 files changed

+315
-0
lines changed

examples/servers/mrtr-options/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,26 @@ identical client-observed behaviour.
1010

1111
[typescript-sdk#1701]: https://github.com/modelcontextprotocol/typescript-sdk/pull/1701
1212

13+
## Start here
14+
15+
If you just want to see what an MRTR lowlevel handler looks like without
16+
the comparison framing, read these first:
17+
18+
- [`basic.py`](mrtr_options/basic.py) — the simple-tool equivalent. One
19+
`IncompleteResult`, one retry, done. ~130 lines, half of which are
20+
comments explaining the two moves every MRTR handler makes.
21+
- [`basic_multiround.py`](mrtr_options/basic_multiround.py) — the
22+
ADO-rules SEP example. Two rounds, with `request_state` carrying
23+
accumulated context across the retry so any server instance can
24+
handle any round.
25+
26+
Both are runnable end-to-end against the in-memory client:
27+
28+
```sh
29+
uv run python -m mrtr_options.basic
30+
uv run python -m mrtr_options.basic_multiround
31+
```
32+
1333
## The quadrant
1434

1535
| Server infra | Pre-MRTR client | MRTR client |
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""The minimal MRTR lowlevel server — the simple-tool equivalent.
2+
3+
No version checks, no comparison framing. Just the two moves every MRTR
4+
handler makes:
5+
6+
1. Check ``params.input_responses`` for the answer to a prior ask.
7+
2. If it's not there, return ``IncompleteResult`` with the ask embedded.
8+
9+
The client SDK (``mcp.client.Client.call_tool``) drives the retry loop —
10+
this handler is invoked once per round with whatever the client collected.
11+
12+
Run against the in-memory client:
13+
14+
uv run python -m mrtr_options.basic
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import anyio
20+
21+
from mcp import types
22+
from mcp.client import Client
23+
from mcp.client.context import ClientRequestContext
24+
from mcp.server import Server, ServerRequestContext
25+
26+
27+
async def on_list_tools(
28+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
29+
) -> types.ListToolsResult:
30+
return types.ListToolsResult(
31+
tools=[
32+
types.Tool(
33+
name="get_weather",
34+
description="Look up weather for a location. Asks which units you want.",
35+
input_schema={
36+
"type": "object",
37+
"properties": {"location": {"type": "string"}},
38+
"required": ["location"],
39+
},
40+
)
41+
]
42+
)
43+
44+
45+
async def on_call_tool(
46+
ctx: ServerRequestContext, params: types.CallToolRequestParams
47+
) -> types.CallToolResult | types.IncompleteResult:
48+
"""The MRTR tool handler. Called once per round."""
49+
location = (params.arguments or {}).get("location", "?")
50+
51+
# ───────────────────────────────────────────────────────────────────────
52+
# Step 1: check if the client has already answered our question.
53+
#
54+
# ``input_responses`` is a dict keyed by the same keys we used in
55+
# ``input_requests`` on the prior round. Each value is the raw result
56+
# the client produced (ElicitResult, CreateMessageResult, ListRootsResult
57+
# — serialized to dict form over the wire).
58+
#
59+
# On the first round, ``input_responses`` is None. On subsequent rounds,
60+
# it contains ONLY the answers to the most recent round's asks — not
61+
# accumulated across rounds. If you need to accumulate, encode it in
62+
# ``request_state`` (see option_f_ctx_once.py / option_g_tool_builder.py).
63+
# ───────────────────────────────────────────────────────────────────────
64+
responses = params.input_responses or {}
65+
prefs = responses.get("unit_prefs")
66+
67+
if prefs is None or prefs.get("action") != "accept":
68+
# ───────────────────────────────────────────────────────────────────
69+
# Step 2: ask. Return IncompleteResult with the embedded request.
70+
#
71+
# The client SDK receives this, dispatches the embedded ElicitRequest
72+
# to its elicitation_callback, and re-invokes this handler with the
73+
# answer in input_responses["unit_prefs"].
74+
#
75+
# Keys are server-assigned and opaque to the client. Pick whatever
76+
# makes the code readable — they just need to be consistent between
77+
# the ask and the check above.
78+
# ───────────────────────────────────────────────────────────────────
79+
return types.IncompleteResult(
80+
input_requests={
81+
"unit_prefs": types.ElicitRequest(
82+
params=types.ElicitRequestFormParams(
83+
message="Which units for the temperature?",
84+
requested_schema={
85+
"type": "object",
86+
"properties": {"units": {"type": "string", "enum": ["metric", "imperial"]}},
87+
"required": ["units"],
88+
},
89+
)
90+
)
91+
},
92+
# request_state is optional. Use it for anything that must
93+
# survive across rounds without server-side storage — e.g.
94+
# partially-computed results, progress markers, or (in F/G)
95+
# idempotency guards. The client echoes it verbatim.
96+
request_state=None,
97+
)
98+
99+
# ───────────────────────────────────────────────────────────────────────
100+
# Step 3: we have the answer. Compute and return a normal result.
101+
# ───────────────────────────────────────────────────────────────────────
102+
units = prefs["content"]["units"]
103+
temp = "22°C" if units == "metric" else "72°F"
104+
return types.CallToolResult(content=[types.TextContent(text=f"Weather in {location}: {temp}, partly cloudy.")])
105+
106+
107+
server = Server("mrtr-basic", on_list_tools=on_list_tools, on_call_tool=on_call_tool)
108+
109+
110+
# ─── Demo driver ─────────────────────────────────────────────────────────────
111+
112+
113+
async def elicitation_callback(context: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult:
114+
"""What the app developer writes. Same signature as SSE-era callbacks."""
115+
assert isinstance(params, types.ElicitRequestFormParams)
116+
print(f"[client] server asks: {params.message}")
117+
# A real client presents params.requested_schema as a form. We hard-code.
118+
return types.ElicitResult(action="accept", content={"units": "metric"})
119+
120+
121+
async def main() -> None:
122+
async with Client(server, elicitation_callback=elicitation_callback) as client:
123+
result = await client.call_tool("get_weather", {"location": "Tokyo"})
124+
print(f"[client] result: {result.content[0].text}") # type: ignore[union-attr]
125+
126+
127+
if __name__ == "__main__":
128+
anyio.run(main)
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""Multi-round MRTR with request_state accumulation.
2+
3+
This is the ADO-custom-rules example from the SEP, translated. Resolving
4+
a work item triggers cascading required fields:
5+
6+
Rule 1: State → "Resolved" requires a Resolution field
7+
Rule 2: Resolution = "Duplicate" requires a "Duplicate Of" link
8+
9+
The server learns Rule 2 is needed only after the user answers Rule 1.
10+
Two rounds of elicitation. The Rule 1 answer must survive across rounds
11+
*without server-side storage* — that's what ``request_state`` is for.
12+
13+
Key point: ``input_responses`` carries only the *latest* round's answers.
14+
Round 2's retry has ``{"duplicate_of": ...}`` but NOT ``{"resolution": ...}``.
15+
Anything the server needs to keep must be encoded in ``request_state``,
16+
which the client echoes verbatim.
17+
18+
Run against the in-memory client:
19+
20+
uv run python -m mrtr_options.basic_multiround
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import base64
26+
import json
27+
from typing import Any
28+
29+
import anyio
30+
31+
from mcp import types
32+
from mcp.client import Client
33+
from mcp.client.context import ClientRequestContext
34+
from mcp.server import Server, ServerRequestContext
35+
36+
37+
def encode_state(state: dict[str, Any]) -> str:
38+
"""Serialize state for the round trip through the client.
39+
40+
Plain base64-JSON here. A production server handling sensitive data
41+
MUST sign this — the client is an untrusted intermediary and could
42+
forge or replay state otherwise. See SEP-2322 §Security Implications.
43+
"""
44+
return base64.b64encode(json.dumps(state).encode()).decode()
45+
46+
47+
def decode_state(blob: str | None) -> dict[str, Any]:
48+
if not blob:
49+
return {}
50+
return json.loads(base64.b64decode(blob))
51+
52+
53+
def ask(message: str, field: str) -> types.ElicitRequest:
54+
"""Build a form-mode elicitation for a single string field."""
55+
return types.ElicitRequest(
56+
params=types.ElicitRequestFormParams(
57+
message=message,
58+
requested_schema={
59+
"type": "object",
60+
"properties": {field: {"type": "string"}},
61+
"required": [field],
62+
},
63+
)
64+
)
65+
66+
67+
async def on_list_tools(
68+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
69+
) -> types.ListToolsResult:
70+
return types.ListToolsResult(
71+
tools=[
72+
types.Tool(
73+
name="resolve_work_item",
74+
description="Resolve a work item. May need cascading follow-up fields.",
75+
input_schema={
76+
"type": "object",
77+
"properties": {"work_item_id": {"type": "integer"}},
78+
"required": ["work_item_id"],
79+
},
80+
)
81+
]
82+
)
83+
84+
85+
async def on_call_tool(
86+
ctx: ServerRequestContext, params: types.CallToolRequestParams
87+
) -> types.CallToolResult | types.IncompleteResult:
88+
args = params.arguments or {}
89+
work_item_id = args.get("work_item_id", 0)
90+
responses = params.input_responses or {}
91+
state = decode_state(params.request_state)
92+
93+
# ───────────────────────────────────────────────────────────────────────
94+
# Round 1: State → Resolved triggers Rule 1 (require Resolution).
95+
#
96+
# If we don't yet have the resolution — neither in this round's
97+
# input_responses nor in accumulated state — ask for it.
98+
# ───────────────────────────────────────────────────────────────────────
99+
resolution = state.get("resolution")
100+
if not resolution:
101+
resp = responses.get("resolution")
102+
if not resp or resp.get("action") != "accept":
103+
return types.IncompleteResult(
104+
input_requests={
105+
"resolution": ask(
106+
f"Resolving #{work_item_id} requires a resolution. Fixed, Won't Fix, Duplicate, or By Design?",
107+
"resolution",
108+
)
109+
},
110+
# No state yet — the original tool arguments are re-sent on
111+
# retry, so we don't need to encode anything for round 1.
112+
)
113+
resolution = resp["content"]["resolution"]
114+
115+
# ───────────────────────────────────────────────────────────────────────
116+
# Round 2: Resolution = "Duplicate" triggers Rule 2 (require link).
117+
#
118+
# If the resolution is Duplicate and we don't yet have the link, ask
119+
# for it — but encode the already-gathered resolution in request_state
120+
# so it survives the round trip regardless of which server instance
121+
# handles the next retry.
122+
# ───────────────────────────────────────────────────────────────────────
123+
if resolution == "Duplicate":
124+
resp = responses.get("duplicate_of")
125+
if not resp or resp.get("action") != "accept":
126+
return types.IncompleteResult(
127+
input_requests={"duplicate_of": ask("Which work item is the original?", "duplicate_of")},
128+
request_state=encode_state({"resolution": resolution}),
129+
)
130+
dup = resp["content"]["duplicate_of"]
131+
text = f"#{work_item_id} resolved as Duplicate of #{dup}."
132+
else:
133+
text = f"#{work_item_id} resolved as {resolution}."
134+
135+
return types.CallToolResult(content=[types.TextContent(text=text)])
136+
137+
138+
server = Server("mrtr-multiround", on_list_tools=on_list_tools, on_call_tool=on_call_tool)
139+
140+
141+
# ─── Demo driver ─────────────────────────────────────────────────────────────
142+
143+
144+
ANSWERS = {
145+
"resolution": "Duplicate",
146+
"duplicate_of": "4301",
147+
}
148+
149+
150+
async def elicitation_callback(context: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult:
151+
assert isinstance(params, types.ElicitRequestFormParams)
152+
print(f"[client] server asks: {params.message}")
153+
# Pick the field name from the schema and answer from our table.
154+
field = next(iter(params.requested_schema["properties"]))
155+
answer = ANSWERS[field]
156+
print(f"[client] answering {field}={answer}")
157+
return types.ElicitResult(action="accept", content={field: answer})
158+
159+
160+
async def main() -> None:
161+
async with Client(server, elicitation_callback=elicitation_callback) as client:
162+
result = await client.call_tool("resolve_work_item", {"work_item_id": 4522})
163+
print(f"[client] final: {result.content[0].text}") # type: ignore[union-attr]
164+
165+
166+
if __name__ == "__main__":
167+
anyio.run(main)

0 commit comments

Comments
 (0)