chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
# 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 ask each question 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 (2026 protocol: the questions ride embedded input_required round-trips;
|
||||
# add --legacy to ride synchronous push elicitation instead)
|
||||
uv run python -m stories.refund_desk.client --http
|
||||
```
|
||||
|
||||
## 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}`. At 2026 the resolver's elicited
|
||||
answers ride between rounds inside a `requestState` the SDK seals by default;
|
||||
see `mrtr/` for the full security walk-through.
|
||||
- `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
|
||||
|
||||
- **Transport per era.** The framework picks the elicitation transport from
|
||||
the negotiated protocol: at >= 2026-07-28 the questions ride embedded
|
||||
`input_required` round-trips (a resolver that depends on another's answer is
|
||||
asked in a later round); at <= 2025-11-25 each is a synchronous
|
||||
`elicitation/create` push request mid-call. Author code is identical on
|
||||
both — this client runs unchanged on either era.
|
||||
- **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 question is asked at most once per call, and
|
||||
within a round each resolver runs at most once, keyed by function identity.
|
||||
Across 2026 rounds only *elicited* outcomes persist (in `requestState`); any
|
||||
resolver's body may run again on each round the call passes through. A
|
||||
recorded answer is consulted only when the resolver asks its question again:
|
||||
it satisfies the question without re-prompting the user, and it never stands
|
||||
in for a value the resolver computes itself.
|
||||
An answer is matched back to its question when the call resumes, so an
|
||||
eliciting resolver must derive its question deterministically from the
|
||||
tool's arguments and earlier answers; a per-call generated value (a
|
||||
`default_factory` id, a timestamp) is re-derived each round and must not
|
||||
appear in a question the answer is meant to bind to. 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),
|
||||
[Input required tool results — server features](https://modelcontextprotocol.io/specification/draft/server/tools#input-required-tool-results)
|
||||
|
||||
## See also
|
||||
|
||||
`mrtr/` (the 2026 `input_required` carrier these questions ride at
|
||||
>= 2026-07-28), `legacy_elicitation/` (the push mechanism they ride on
|
||||
handshake-era connections).
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Prove the refund amount is schema-hidden, resolvers memoize per call, and decline semantics differ per consumer."""
|
||||
|
||||
import mcp_types as types
|
||||
|
||||
from mcp.client import Client, ClientRequestContext
|
||||
from stories._harness import Target, run_client
|
||||
|
||||
|
||||
async def main(target: Target, *, mode: str = "auto") -> None:
|
||||
# Scripted answers + per-topic counters; topics in `declines` are refused.
|
||||
counts = {"scope": 0, "restock": 0}
|
||||
answers: dict[str, dict[str, str | int | float | bool | list[str] | None]] = {
|
||||
"scope": {"full": True},
|
||||
"restock": {"restock": True},
|
||||
}
|
||||
declines: set[str] = set()
|
||||
|
||||
async def on_elicit(context: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult:
|
||||
assert isinstance(params, types.ElicitRequestFormParams)
|
||||
topic = "scope" if "full" in params.requested_schema["properties"] else "restock"
|
||||
counts[topic] += 1
|
||||
if topic in declines:
|
||||
return types.ElicitResult(action="decline")
|
||||
return types.ElicitResult(action="accept", content=answers[topic])
|
||||
|
||||
async with Client(target, mode=mode, elicitation_callback=on_elicit) as client:
|
||||
# The model-facing contract is order_id + reason only; cents and restock are resolver-filled.
|
||||
listed = await client.list_tools()
|
||||
(tool,) = listed.tools
|
||||
assert set(tool.input_schema["properties"]) == {"order_id", "reason"}, tool.input_schema
|
||||
assert set(tool.input_schema.get("required", ())) == {"order_id", "reason"}, tool.input_schema
|
||||
|
||||
# One digital line: scope auto-fills (full), restock auto-fills (False) — zero round-trips.
|
||||
receipt = await client.call_tool("refund_order", {"order_id": "ORD-7001", "reason": "download corrupted"})
|
||||
assert receipt.structured_content == {
|
||||
"order_id": "ORD-7001",
|
||||
"refunded_cents": 1500,
|
||||
"restocked": False,
|
||||
"reason": "download corrupted",
|
||||
}, receipt.structured_content
|
||||
assert counts == {"scope": 0, "restock": 0}, counts
|
||||
|
||||
# Full refund of a three-line order. The scope question fires exactly ONCE even though
|
||||
# both refund_amount and ask_restock consume it — asked at most once per call on either
|
||||
# era. ask_restock needs the scope ANSWER, so at 2026 the two questions land in
|
||||
# successive rounds, never one concurrent batch: counts and order are era-independent.
|
||||
receipt = await client.call_tool("refund_order", {"order_id": "ORD-7002", "reason": "arrived broken"})
|
||||
assert receipt.structured_content == {
|
||||
"order_id": "ORD-7002",
|
||||
"refunded_cents": 4800,
|
||||
"restocked": True,
|
||||
"reason": "arrived broken",
|
||||
}, receipt.structured_content
|
||||
assert counts == {"scope": 1, "restock": 1}, counts
|
||||
|
||||
# Declining restock still refunds: the tool keeps the ElicitationResult union for
|
||||
# `restock`, sees the decline, and just skips the restock. The scope counter moves
|
||||
# again — questions are deduped per call, not per connection.
|
||||
declines.add("restock")
|
||||
answers["scope"] = {"full": False, "sku": "canvas-tote"}
|
||||
receipt = await client.call_tool("refund_order", {"order_id": "ORD-7002", "reason": "wrong colour"})
|
||||
assert receipt.structured_content == {
|
||||
"order_id": "ORD-7002",
|
||||
"refunded_cents": 2400,
|
||||
"restocked": False,
|
||||
"reason": "wrong colour",
|
||||
}, receipt.structured_content
|
||||
assert counts == {"scope": 2, "restock": 2}, counts
|
||||
declines.clear()
|
||||
|
||||
# An elicited SKU is human-typed: the server validates it against the order before
|
||||
# any money is computed.
|
||||
answers["scope"] = {"full": False, "sku": "mystery-hat"}
|
||||
result = await client.call_tool("refund_order", {"order_id": "ORD-7002", "reason": "lost parcel"})
|
||||
assert result.is_error, result
|
||||
assert isinstance(result.content[0], types.TextContent)
|
||||
assert "order has no item 'mystery-hat'" in result.content[0].text, result.content[0].text
|
||||
|
||||
# Declining scope aborts the whole call: refund_amount and ask_restock both consume scope
|
||||
# unwrapped, so whichever resolves first (`cents`, in signature order) aborts, and
|
||||
# ask_restock never runs under any order.
|
||||
declines.add("scope")
|
||||
restock_before = counts["restock"]
|
||||
result = await client.call_tool("refund_order", {"order_id": "ORD-7002", "reason": "changed mind"})
|
||||
assert result.is_error, result
|
||||
assert isinstance(result.content[0], types.TextContent)
|
||||
assert "Resolver for parameter 'scope' could not resolve: elicitation was decline" in result.content[0].text, (
|
||||
result.content[0].text
|
||||
)
|
||||
assert counts["restock"] == restock_before, counts
|
||||
declines.clear()
|
||||
|
||||
# A ToolError raised inside a resolver surfaces exactly like one from the tool body.
|
||||
result = await client.call_tool("refund_order", {"order_id": "ORD-9999", "reason": "typo"})
|
||||
assert result.is_error, result
|
||||
assert isinstance(result.content[0], types.TextContent)
|
||||
assert "unknown order 'ORD-9999'" in result.content[0].text, result.content[0].text
|
||||
|
||||
# Full elicitation trajectory: scope fired in legs 2-5 (memoized within each call),
|
||||
# restock only in the two calls that reached it.
|
||||
assert counts == {"scope": 4, "restock": 2}, counts
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_client(main)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Resolver DI: the refund amount is computed by resolvers from the order record — `cents` never appears in the
|
||||
tool's input schema, so the model cannot supply or inflate it."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mcp.server.mcpserver import (
|
||||
AcceptedElicitation,
|
||||
Elicit,
|
||||
ElicitationResult,
|
||||
MCPServer,
|
||||
Resolve,
|
||||
)
|
||||
from mcp.server.mcpserver.exceptions import ToolError
|
||||
from stories._hosting import run_server_from_args
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Line:
|
||||
sku: str
|
||||
cents: int
|
||||
physical: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Order:
|
||||
order_id: str
|
||||
lines: tuple[Line, ...]
|
||||
|
||||
|
||||
ORDERS: dict[str, Order] = {
|
||||
"ORD-7001": Order("ORD-7001", (Line("ebook-fieldnotes", 1500, physical=False),)),
|
||||
"ORD-7002": Order(
|
||||
"ORD-7002",
|
||||
(
|
||||
Line("enamel-mug", 1800, physical=True),
|
||||
Line("canvas-tote", 2400, physical=True),
|
||||
Line("sticker-pack", 600, physical=False),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class Scope(BaseModel):
|
||||
"""Which items to refund: the whole order, or a single SKU."""
|
||||
|
||||
full: bool
|
||||
sku: str = ""
|
||||
|
||||
|
||||
class RestockChoice(BaseModel):
|
||||
restock: bool
|
||||
|
||||
|
||||
class Receipt(BaseModel):
|
||||
order_id: str
|
||||
refunded_cents: int
|
||||
restocked: bool
|
||||
reason: str
|
||||
|
||||
|
||||
def load_order(order_id: str) -> Order:
|
||||
order = ORDERS.get(order_id)
|
||||
if order is None:
|
||||
raise ToolError(f"unknown order {order_id!r}")
|
||||
return order
|
||||
|
||||
|
||||
def refund_scope(order_id: str, order: Annotated[Order, Resolve(load_order)]) -> Scope | Elicit[Scope]:
|
||||
if len(order.lines) == 1:
|
||||
return Scope(full=True)
|
||||
skus = ", ".join(line.sku for line in order.lines)
|
||||
return Elicit(f"{order_id} has several items ({skus}). Refund the whole order, or one SKU?", Scope)
|
||||
|
||||
|
||||
def _scoped(order: Order, scope: Scope) -> tuple[Line, ...]:
|
||||
"""The lines a scope covers. The SKU was typed by a human — validate it against the order."""
|
||||
if scope.full:
|
||||
return order.lines
|
||||
lines = tuple(line for line in order.lines if line.sku == scope.sku)
|
||||
if not lines:
|
||||
raise ToolError(f"order has no item {scope.sku!r}")
|
||||
return lines
|
||||
|
||||
|
||||
def refund_amount(
|
||||
order: Annotated[Order, Resolve(load_order)],
|
||||
scope: Annotated[Scope, Resolve(refund_scope)],
|
||||
) -> int:
|
||||
return sum(line.cents for line in _scoped(order, scope))
|
||||
|
||||
|
||||
def ask_restock(
|
||||
order: Annotated[Order, Resolve(load_order)],
|
||||
scope: Annotated[Scope, Resolve(refund_scope)],
|
||||
) -> RestockChoice | Elicit[RestockChoice]:
|
||||
physical = [line.sku for line in _scoped(order, scope) if line.physical]
|
||||
if not physical:
|
||||
return RestockChoice(restock=False)
|
||||
return Elicit(f"The refund includes physical items ({', '.join(physical)}). Return them to stock?", RestockChoice)
|
||||
|
||||
|
||||
def build_server() -> MCPServer:
|
||||
# Elicited answers ride between rounds in a requestState the SDK seals by default;
|
||||
# see mrtr/ for the full security walk-through.
|
||||
mcp = MCPServer("refund-desk")
|
||||
|
||||
@mcp.tool(description="Refund an order. The amount comes from the order record, not from the caller.")
|
||||
def refund_order(
|
||||
order_id: str,
|
||||
reason: str,
|
||||
cents: Annotated[int, Resolve(refund_amount)],
|
||||
restock: Annotated[ElicitationResult[RestockChoice], Resolve(ask_restock)],
|
||||
) -> Receipt:
|
||||
# `restock` keeps the full elicitation outcome: a declined restock still refunds. A plain
|
||||
# (non-Elicit) resolver return arrives wrapped as an accepted outcome, so the fast path
|
||||
# lands in the same `AcceptedElicitation` branch.
|
||||
restocked = isinstance(restock, AcceptedElicitation) and restock.data.restock
|
||||
return Receipt(order_id=order_id, refunded_cents=cents, restocked=restocked, reason=reason)
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_server_from_args(build_server)
|
||||
Reference in New Issue
Block a user