-
Notifications
You must be signed in to change notification settings - Fork 3.4k
Expand file tree
/
Copy pathoption_g_tool_builder.py
More file actions
68 lines (50 loc) · 3.98 KB
/
option_g_tool_builder.py
File metadata and controls
68 lines (50 loc) · 3.98 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
"""Option G: ``ToolBuilder`` — explicit step decomposition.
The monolithic handler becomes a sequence of named step functions.
``incomplete_step`` may return ``IncompleteResult`` (needs more input)
or a dict (satisfied, pass to next step). ``end_step`` receives
everything and runs exactly once — structurally unreachable until
every prior step has returned data.
The footgun is eliminated by code shape, not discipline. There is no
"above the guard" zone because there is no guard — the SDK's step
tracking (via ``request_state``) *is* the guard. Side-effects go in
``end_step``; anything in an ``incomplete_step`` is documented as
must-be-idempotent, and the return-type split makes that distinction
visible at the function signature level.
Boilerplate: two function defs + ``.build()`` to replace E's 3-line
guard. Worth it at 3+ rounds or when the side-effect story matters.
Overkill for a single-question tool where F is lighter.
"""
from __future__ import annotations
from typing import Any
from mcp import types
from mcp.server.experimental.mrtr import ToolBuilder
from ._shared import UNITS_REQUEST, audit_log, build_server, lookup_weather
# ───────────────────────────────────────────────────────────────────────────
# Step 1: ask for units. Returns IncompleteResult if not yet provided,
# or ``{"units": ...}`` to pass forward. MUST be idempotent — it can
# re-run if request_state is tampered with (unsigned in this draft) or
# on a partial replay. No side-effects here.
# ───────────────────────────────────────────────────────────────────────────
def ask_units(args: dict[str, Any], inputs: dict[str, Any]) -> types.IncompleteResult | dict[str, Any]:
resp = inputs.get("units")
if not resp or resp.get("action") != "accept":
return types.IncompleteResult(input_requests={"units": UNITS_REQUEST})
return {"units": resp["content"]["units"]}
# ───────────────────────────────────────────────────────────────────────────
# End step: has everything, does the work. Runs exactly once. This is
# where side-effects live — the SDK guarantees this function is not
# reached until ``ask_units`` (and any other incomplete steps) have all
# returned data. ``audit_log`` here fires once regardless of how many
# MRTR rounds it took to collect the inputs.
# ───────────────────────────────────────────────────────────────────────────
def fetch_weather(args: dict[str, Any], collected: dict[str, Any]) -> types.CallToolResult:
location = (args or {}).get("location", "?")
audit_log(location)
return types.CallToolResult(content=[types.TextContent(text=lookup_weather(location, collected["units"]))])
# ───────────────────────────────────────────────────────────────────────────
# Assembly. Steps are named so reordering during development doesn't
# silently remap data. The builder output is directly a lowlevel
# ``on_call_tool`` handler — no extra wrapping.
# ───────────────────────────────────────────────────────────────────────────
weather = ToolBuilder[dict[str, Any]]().incomplete_step("ask_units", ask_units).end_step(fetch_weather).build()
server = build_server("mrtr-option-g", on_call_tool=weather)