67 lines
3.3 KiB
Markdown
67 lines
3.3 KiB
Markdown
# refund-desk
|
|
|
|
Resolver dependency injection: a tool parameter annotated `Annotated[T,
|
|
Resolve(fn)]` is filled by running the resolver `fn` before the tool body,
|
|
instead of from the LLM-supplied arguments. Here `refund_order(order_id,
|
|
reason)` refunds what the order record says — `cents` is resolver-computed and
|
|
does not appear in the input schema at all, so the model cannot supply or
|
|
inflate the amount. Resolvers form a DAG (`load_order` → `refund_scope` →
|
|
`refund_amount` / `ask_restock`), may return `Elicit[...]` to ask the human,
|
|
and run at most once per call. A resolver's own plain parameters are filled
|
|
from the tool's arguments by name — `load_order(order_id)` receives the
|
|
`order_id` the model passed to `refund_order`.
|
|
|
|
## Run it
|
|
|
|
```bash
|
|
# stdio (default — the client spawns the server as a subprocess)
|
|
uv run python -m stories.refund_desk.client
|
|
|
|
# HTTP — the client self-hosts the server on a free port, runs, then tears it
|
|
# down (--legacy: resolver elicitation rides the push request today; the
|
|
# manifest pins this era, so bare --http runs the same leg)
|
|
uv run python -m stories.refund_desk.client --http --legacy
|
|
```
|
|
|
|
## What to look at
|
|
|
|
- `server.py` `refund_order` — the signature is the whole story: `order_id` and
|
|
`reason` are model-facing; `cents` and `restock` carry `Resolve(...)` markers
|
|
and never reach the input schema. `client.py` asserts `properties` and
|
|
`required` are exactly `{order_id, reason}`.
|
|
- `server.py` `refund_scope` — the no-round-trip fast path: a one-line order
|
|
returns `Scope(full=True)` directly; only a multi-line order returns
|
|
`Elicit(...)`. The ORD-7001 call completes with zero elicitations.
|
|
- `server.py` `_scoped` — the elicited SKU is human-typed free text; it is
|
|
validated against the order (`ToolError` on a miss) before any amount is
|
|
computed.
|
|
- The decline contrast: `refund_amount` takes `scope` **unwrapped**, so
|
|
declining the scope question aborts the whole `cents` chain with an error
|
|
containing the framework's
|
|
`Resolver for parameter 'scope' could not resolve: elicitation was decline`
|
|
(the client sees it behind the usual `Error executing tool refund_order:`
|
|
prefix); `restock` keeps the `ElicitationResult` union, so declining restock
|
|
still refunds — just with `restocked: false`.
|
|
- `client.py` — the scope counter proves memoization from outside: one call
|
|
consumes `refund_scope` from two resolvers but the question fires once.
|
|
|
|
## Caveats
|
|
|
|
- **Decline order.** A declined unwrapped dependency aborts resolution in
|
|
tool-signature order — `cents` resolves before `restock`, so `ask_restock`
|
|
never runs. Don't rely on a later resolver's side effects after an earlier
|
|
consumer can abort.
|
|
- **Memoization scope.** Each resolver runs at most once per `tools/call`,
|
|
keyed by function identity; nothing is cached across calls or connections.
|
|
- **Validate elicited values.** Elicited answers are human-typed; check them
|
|
against your records (as `_scoped` does) before acting on them.
|
|
|
|
## Spec
|
|
|
|
[Elicitation — client features](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation)
|
|
|
|
## See also
|
|
|
|
`legacy_elicitation/` (the push mechanism resolver elicitation rides on today),
|
|
`mrtr/` (the 2026 `input_required` carrier; resolver DI will ride it once the
|
|
SDK wires them together).
|