Skip to content

Commit 1795a2d

Browse files
committed
Add refund_desk story: resolver-injected parameters hidden from the schema
A back-office refund server where the amount is computed by resolvers from the order record and never appears in the tool's input schema, so the model cannot supply it. The story exercises the resolver DAG (load_order -> refund_scope -> refund_amount / ask_restock), the no-round-trip fast path, per-call memoization observable from the client, validation of elicited free text, and both decline semantics: an unwrapped dependency aborts the call, the ElicitationResult union lets the tool branch.
1 parent b2e0ba3 commit 1795a2d

8 files changed

Lines changed: 306 additions & 2 deletions

File tree

examples/stories/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ opens with a banner saying what replaces it.
130130
| [`streaming`](streaming/) | progress notifications, in-flight logging, cancellation | current |
131131
| [`mrtr`](mrtr/) | `InputRequiredResult` round-trip: the `Client` auto-loop and a manual session-level loop | current |
132132
| [`legacy_elicitation`](legacy_elicitation/) | server pauses a tool to ask the user (form + url) via a push request | legacy |
133+
| [`refund_desk`](refund_desk/) | resolver DI: `Annotated[T, Resolve(fn)]` params filled server-side, hidden from the input schema | current |
133134
| [`sampling`](sampling/) | server asks the client's LLM mid-tool (push request) | deprecated |
134135
| [`stickynotes`](stickynotes/) | capstone: tools mutate state → resources + `list_changed` + elicit guard | current |
135136
| [`custom_methods`](custom_methods/) | vendor-prefixed JSON-RPC via `add_request_handler` / `send_request` | current |

examples/stories/legacy_elicitation/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,5 @@ uv run python -m stories.legacy_elicitation.client --http --legacy --server serv
6969

7070
`sampling/` (same push-request shape, deprecated per SEP-2577), `mrtr/`
7171
(planned — the 2026-era carrier), `error_handling/`
72-
(`UrlElicitationRequiredError`).
72+
(`UrlElicitationRequiredError`), `refund_desk/` (resolver DI rides this push
73+
mechanism today).

examples/stories/manifest.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ era = "modern"
3939
era = "legacy"
4040
status = "legacy"
4141

42+
[story.refund_desk]
43+
# Resolver DI rides push elicitation (ctx.elicit) today; era flips to "dual" once
44+
# the SDK carries resolver elicitation over the 2026 input_required round-trip.
45+
era = "legacy"
46+
lowlevel = false
47+
4248
[story.sampling]
4349
era = "legacy"
4450
status = "deprecated"

examples/stories/mrtr/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,5 @@ uv run python -m stories.mrtr.client --http --server server_lowlevel
5151
## See also
5252

5353
`legacy_elicitation/` and `sampling/` — the handshake-era push equivalents this
54-
mechanism replaces on the 2026 protocol.
54+
mechanism replaces on the 2026 protocol. `refund_desk/` — resolver DI at the
55+
MCPServer tier: the questions a tool can declare instead of pushing by hand.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# refund-desk
2+
3+
Resolver dependency injection: a tool parameter annotated `Annotated[T,
4+
Resolve(fn)]` is filled by running the resolver `fn` before the tool body,
5+
instead of from the LLM-supplied arguments. Here `refund_order(order_id,
6+
reason)` refunds what the order record says — `cents` is resolver-computed and
7+
does not appear in the input schema at all, so the model cannot supply or
8+
inflate the amount. Resolvers form a DAG (`load_order``refund_scope`
9+
`refund_amount` / `ask_restock`), may return `Elicit[...]` to ask the human,
10+
and run at most once per call. A resolver's own plain parameters are filled
11+
from the tool's arguments by name — `load_order(order_id)` receives the
12+
`order_id` the model passed to `refund_order`.
13+
14+
## Run it
15+
16+
```bash
17+
# stdio (default — the client spawns the server as a subprocess)
18+
uv run python -m stories.refund_desk.client
19+
20+
# HTTP — the client self-hosts the server on a free port, runs, then tears it
21+
# down (--legacy: resolver elicitation rides the push request today; the
22+
# manifest pins this era, so bare --http runs the same leg)
23+
uv run python -m stories.refund_desk.client --http --legacy
24+
```
25+
26+
## What to look at
27+
28+
- `server.py` `refund_order` — the signature is the whole story: `order_id` and
29+
`reason` are model-facing; `cents` and `restock` carry `Resolve(...)` markers
30+
and never reach the input schema. `client.py` asserts `properties` and
31+
`required` are exactly `{order_id, reason}`.
32+
- `server.py` `refund_scope` — the no-round-trip fast path: a one-line order
33+
returns `Scope(full=True)` directly; only a multi-line order returns
34+
`Elicit(...)`. The ORD-7001 call completes with zero elicitations.
35+
- `server.py` `_scoped` — the elicited SKU is human-typed free text; it is
36+
validated against the order (`ToolError` on a miss) before any amount is
37+
computed.
38+
- The decline contrast: `refund_amount` takes `scope` **unwrapped**, so
39+
declining the scope question aborts the whole `cents` chain with an error
40+
containing the framework's
41+
`Resolver for parameter 'scope' could not resolve: elicitation was decline`
42+
(the client sees it behind the usual `Error executing tool refund_order:`
43+
prefix); `restock` keeps the `ElicitationResult` union, so declining restock
44+
still refunds — just with `restocked: false`.
45+
- `client.py` — the scope counter proves memoization from outside: one call
46+
consumes `refund_scope` from two resolvers but the question fires once.
47+
48+
## Caveats
49+
50+
- **Decline order.** A declined unwrapped dependency aborts resolution in
51+
tool-signature order — `cents` resolves before `restock`, so `ask_restock`
52+
never runs. Don't rely on a later resolver's side effects after an earlier
53+
consumer can abort.
54+
- **Memoization scope.** Each resolver runs at most once per `tools/call`,
55+
keyed by function identity; nothing is cached across calls or connections.
56+
- **Validate elicited values.** Elicited answers are human-typed; check them
57+
against your records (as `_scoped` does) before acting on them.
58+
59+
## Spec
60+
61+
[Elicitation — client features](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation)
62+
63+
## See also
64+
65+
`legacy_elicitation/` (the push mechanism resolver elicitation rides on today),
66+
`mrtr/` (the 2026 `input_required` carrier; resolver DI will ride it once the
67+
SDK wires them together).

examples/stories/refund_desk/__init__.py

Whitespace-only changes.
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Prove the refund amount is schema-hidden, resolvers memoize per call, and decline semantics differ per consumer."""
2+
3+
import mcp_types as types
4+
5+
from mcp.client import Client, ClientRequestContext
6+
from stories._harness import Target, run_client
7+
8+
9+
async def main(target: Target, *, mode: str = "auto") -> None:
10+
# Scripted answers + per-topic counters; topics in `declines` are refused.
11+
counts = {"scope": 0, "restock": 0}
12+
answers: dict[str, dict[str, str | int | float | bool | list[str] | None]] = {
13+
"scope": {"full": True},
14+
"restock": {"restock": True},
15+
}
16+
declines: set[str] = set()
17+
18+
async def on_elicit(context: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult:
19+
assert isinstance(params, types.ElicitRequestFormParams)
20+
topic = "scope" if "full" in params.requested_schema["properties"] else "restock"
21+
counts[topic] += 1
22+
if topic in declines:
23+
return types.ElicitResult(action="decline")
24+
return types.ElicitResult(action="accept", content=answers[topic])
25+
26+
async with Client(target, mode=mode, elicitation_callback=on_elicit) as client:
27+
# The model-facing contract is order_id + reason only; cents and restock are resolver-filled.
28+
listed = await client.list_tools()
29+
(tool,) = listed.tools
30+
assert set(tool.input_schema["properties"]) == {"order_id", "reason"}, tool.input_schema
31+
assert set(tool.input_schema.get("required", ())) == {"order_id", "reason"}, tool.input_schema
32+
33+
# One digital line: scope auto-fills (full), restock auto-fills (False) — zero round-trips.
34+
receipt = await client.call_tool("refund_order", {"order_id": "ORD-7001", "reason": "download corrupted"})
35+
assert receipt.structured_content == {
36+
"order_id": "ORD-7001",
37+
"refunded_cents": 1500,
38+
"restocked": False,
39+
"reason": "download corrupted",
40+
}, receipt.structured_content
41+
assert counts == {"scope": 0, "restock": 0}, counts
42+
43+
# Full refund of a three-line order. The scope question fires exactly ONCE even though
44+
# both refund_amount and ask_restock consume it — memoized within the call.
45+
receipt = await client.call_tool("refund_order", {"order_id": "ORD-7002", "reason": "arrived broken"})
46+
assert receipt.structured_content == {
47+
"order_id": "ORD-7002",
48+
"refunded_cents": 4800,
49+
"restocked": True,
50+
"reason": "arrived broken",
51+
}, receipt.structured_content
52+
assert counts == {"scope": 1, "restock": 1}, counts
53+
54+
# Declining restock still refunds: the tool keeps the ElicitationResult union for
55+
# `restock`, sees the decline, and just skips the restock. The scope counter moves
56+
# again — the memo cache is per tools/call, not per connection.
57+
declines.add("restock")
58+
answers["scope"] = {"full": False, "sku": "canvas-tote"}
59+
receipt = await client.call_tool("refund_order", {"order_id": "ORD-7002", "reason": "wrong colour"})
60+
assert receipt.structured_content == {
61+
"order_id": "ORD-7002",
62+
"refunded_cents": 2400,
63+
"restocked": False,
64+
"reason": "wrong colour",
65+
}, receipt.structured_content
66+
assert counts == {"scope": 2, "restock": 2}, counts
67+
declines.clear()
68+
69+
# An elicited SKU is human-typed: the server validates it against the order before
70+
# any money is computed.
71+
answers["scope"] = {"full": False, "sku": "mystery-hat"}
72+
result = await client.call_tool("refund_order", {"order_id": "ORD-7002", "reason": "lost parcel"})
73+
assert result.is_error, result
74+
assert isinstance(result.content[0], types.TextContent)
75+
assert "order has no item 'mystery-hat'" in result.content[0].text, result.content[0].text
76+
77+
# Declining scope aborts the whole call: refund_amount and ask_restock both consume scope
78+
# unwrapped, so whichever resolves first (`cents`, in signature order) aborts, and
79+
# ask_restock never runs under any order.
80+
declines.add("scope")
81+
restock_before = counts["restock"]
82+
result = await client.call_tool("refund_order", {"order_id": "ORD-7002", "reason": "changed mind"})
83+
assert result.is_error, result
84+
assert isinstance(result.content[0], types.TextContent)
85+
assert "Resolver for parameter 'scope' could not resolve: elicitation was decline" in result.content[0].text, (
86+
result.content[0].text
87+
)
88+
assert counts["restock"] == restock_before, counts
89+
declines.clear()
90+
91+
# A ToolError raised inside a resolver surfaces exactly like one from the tool body.
92+
result = await client.call_tool("refund_order", {"order_id": "ORD-9999", "reason": "typo"})
93+
assert result.is_error, result
94+
assert isinstance(result.content[0], types.TextContent)
95+
assert "unknown order 'ORD-9999'" in result.content[0].text, result.content[0].text
96+
97+
# Full elicitation trajectory: scope fired in legs 2-5 (memoized within each call),
98+
# restock only in the two calls that reached it.
99+
assert counts == {"scope": 4, "restock": 2}, counts
100+
101+
102+
if __name__ == "__main__":
103+
run_client(main)
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""Resolver DI: the refund amount is computed by resolvers from the order record — `cents` never appears in the
2+
tool's input schema, so the model cannot supply or inflate it."""
3+
4+
from dataclasses import dataclass
5+
from typing import Annotated
6+
7+
from pydantic import BaseModel
8+
9+
from mcp.server.mcpserver import (
10+
AcceptedElicitation,
11+
Elicit,
12+
ElicitationResult,
13+
MCPServer,
14+
Resolve,
15+
)
16+
from mcp.server.mcpserver.exceptions import ToolError
17+
from stories._hosting import run_server_from_args
18+
19+
20+
@dataclass(frozen=True)
21+
class Line:
22+
sku: str
23+
cents: int
24+
physical: bool
25+
26+
27+
@dataclass(frozen=True)
28+
class Order:
29+
order_id: str
30+
lines: tuple[Line, ...]
31+
32+
33+
ORDERS: dict[str, Order] = {
34+
"ORD-7001": Order("ORD-7001", (Line("ebook-fieldnotes", 1500, physical=False),)),
35+
"ORD-7002": Order(
36+
"ORD-7002",
37+
(
38+
Line("enamel-mug", 1800, physical=True),
39+
Line("canvas-tote", 2400, physical=True),
40+
Line("sticker-pack", 600, physical=False),
41+
),
42+
),
43+
}
44+
45+
46+
class Scope(BaseModel):
47+
"""Which items to refund: the whole order, or a single SKU."""
48+
49+
full: bool
50+
sku: str = ""
51+
52+
53+
class RestockChoice(BaseModel):
54+
restock: bool
55+
56+
57+
class Receipt(BaseModel):
58+
order_id: str
59+
refunded_cents: int
60+
restocked: bool
61+
reason: str
62+
63+
64+
def load_order(order_id: str) -> Order:
65+
order = ORDERS.get(order_id)
66+
if order is None:
67+
raise ToolError(f"unknown order {order_id!r}")
68+
return order
69+
70+
71+
def refund_scope(order_id: str, order: Annotated[Order, Resolve(load_order)]) -> Scope | Elicit[Scope]:
72+
if len(order.lines) == 1:
73+
return Scope(full=True)
74+
skus = ", ".join(line.sku for line in order.lines)
75+
return Elicit(f"{order_id} has several items ({skus}). Refund the whole order, or one SKU?", Scope)
76+
77+
78+
def _scoped(order: Order, scope: Scope) -> tuple[Line, ...]:
79+
"""The lines a scope covers. The SKU was typed by a human — validate it against the order."""
80+
if scope.full:
81+
return order.lines
82+
lines = tuple(line for line in order.lines if line.sku == scope.sku)
83+
if not lines:
84+
raise ToolError(f"order has no item {scope.sku!r}")
85+
return lines
86+
87+
88+
def refund_amount(
89+
order: Annotated[Order, Resolve(load_order)],
90+
scope: Annotated[Scope, Resolve(refund_scope)],
91+
) -> int:
92+
return sum(line.cents for line in _scoped(order, scope))
93+
94+
95+
def ask_restock(
96+
order: Annotated[Order, Resolve(load_order)],
97+
scope: Annotated[Scope, Resolve(refund_scope)],
98+
) -> RestockChoice | Elicit[RestockChoice]:
99+
physical = [line.sku for line in _scoped(order, scope) if line.physical]
100+
if not physical:
101+
return RestockChoice(restock=False)
102+
return Elicit(f"The refund includes physical items ({', '.join(physical)}). Return them to stock?", RestockChoice)
103+
104+
105+
def build_server() -> MCPServer:
106+
mcp = MCPServer("refund-desk")
107+
108+
@mcp.tool(description="Refund an order. The amount comes from the order record, not from the caller.")
109+
def refund_order(
110+
order_id: str,
111+
reason: str,
112+
cents: Annotated[int, Resolve(refund_amount)],
113+
restock: Annotated[ElicitationResult[RestockChoice], Resolve(ask_restock)],
114+
) -> Receipt:
115+
# `restock` keeps the full elicitation outcome: a declined restock still refunds. A plain
116+
# (non-Elicit) resolver return arrives wrapped as an accepted outcome, so the fast path
117+
# lands in the same `AcceptedElicitation` branch.
118+
restocked = isinstance(restock, AcceptedElicitation) and restock.data.restock
119+
return Receipt(order_id=order_id, refunded_cents=cents, restocked=restocked, reason=reason)
120+
121+
return mcp
122+
123+
124+
if __name__ == "__main__":
125+
run_server_from_args(build_server)

0 commit comments

Comments
 (0)