Add resolver dependency injection for MCPServer tools (#2969)

Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
This commit is contained in:
Marcelo Trylesinski 2026-06-29 12:51:46 +02:00 • committed by GitHub
commit f664db8952
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1844 additions and 15 deletions

View file

@ -130,6 +130,7 @@ opens with a banner saying what replaces it.
| [`streaming`](streaming/) | progress notifications, in-flight logging, cancellation | current |
| [`mrtr`](mrtr/) | `InputRequiredResult` round-trip: the `Client` auto-loop and a manual session-level loop | current |
| [`legacy_elicitation`](legacy_elicitation/) | server pauses a tool to ask the user (form + url) via a push request | legacy |
| [`refund_desk`](refund_desk/) | resolver DI: `Annotated[T, Resolve(fn)]` params filled server-side, hidden from the input schema | current |
| [`sampling`](sampling/) | server asks the client's LLM mid-tool (push request) | deprecated |
| [`stickynotes`](stickynotes/) | capstone: tools mutate state → resources + `list_changed` + elicit guard | current |
| [`custom_methods`](custom_methods/) | vendor-prefixed JSON-RPC via `add_request_handler` / `send_request` | current |

View file

@ -69,4 +69,5 @@ uv run python -m stories.legacy_elicitation.client --http --legacy --server serv
`sampling/` (same push-request shape, deprecated per SEP-2577), `mrtr/`
(planned — the 2026-era carrier), `error_handling/`
(`UrlElicitationRequiredError`).
(`UrlElicitationRequiredError`), `refund_desk/` (resolver DI rides this push
mechanism today).

View file

@ -39,6 +39,12 @@ era = "modern"
era = "legacy"
status = "legacy"
[story.refund_desk]
# Resolver DI rides push elicitation (ctx.elicit) today; era flips to "dual" once
# the SDK carries resolver elicitation over the 2026 input_required round-trip.
era = "legacy"
lowlevel = false
[story.sampling]
era = "legacy"
status = "deprecated"

View file

@ -51,4 +51,5 @@ uv run python -m stories.mrtr.client --http --server server_lowlevel
## See also
`legacy_elicitation/` and `sampling/` — the handshake-era push equivalents this
mechanism replaces on the 2026 protocol.
mechanism replaces on the 2026 protocol. `refund_desk/` — resolver DI at the
MCPServer tier: the questions a tool can declare instead of pushing by hand.

View file

@ -0,0 +1,67 @@
# 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 run 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 (--legacy: resolver elicitation rides the push request today; the
# manifest pins this era, so bare --http runs the same leg)
uv run python -m stories.refund_desk.client --http --legacy
```
## 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}`.
- `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
- **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 resolver runs at most once per `tools/call`,
keyed by function identity; 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)
## See also
`legacy_elicitation/` (the push mechanism resolver elicitation rides on today),
`mrtr/` (the 2026 `input_required` carrier; resolver DI will ride it once the
SDK wires them together).

View file

View file

@ -0,0 +1,103 @@
"""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 — memoized within the call.
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 — the memo cache is per tools/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)

View file

@ -0,0 +1,125 @@
"""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:
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)