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
106 lines
5.4 KiB
Python
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)
|