-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Expand file tree
/
Copy pathclient.py
More file actions
105 lines (90 loc) · 5.39 KB
/
Copy pathclient.py
File metadata and controls
105 lines (90 loc) · 5.39 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
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)