Files
wehub-resource-sync 49b9bb6724
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
chore: import upstream snapshot with attribution
2026-07-13 12:10:27 +08:00

106 lines
5.4 KiB
Python

"""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)