Skip to content

Commit 0016c5f

Browse files
committed
Add an interaction-level feature walk for the resolver contract
Eight public-API-only tests, one behaviour each: server-side injection with no client involvement, resolver dependencies, client-supplied values for resolved parameters being discarded, the advertised schema hiding resolved parameters, elicit accept/decline for plain-model and outcome-union consumers, and the missing-capability refusal - the client-facing ones parametrized over the 2025 and 2026 protocol eras.
1 parent b30ab0f commit 0016c5f

1 file changed

Lines changed: 200 additions & 0 deletions

File tree

tests/server/mcpserver/test_resolve.py

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import anyio
99
import pytest
10+
from inline_snapshot import snapshot
1011
from mcp_types import (
1112
MISSING_REQUIRED_CLIENT_CAPABILITY,
1213
CallToolResult,
@@ -2780,3 +2781,202 @@ async def calc(answer: Annotated[CreateMessageResultWithTools, Resolve(_ask_with
27802781
sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability()),
27812782
) as client:
27822783
assert await _text(client, "calc", {}) == "4"
2784+
2785+
2786+
# --- Feature walk: interaction-level tests (public API only, one behaviour per test) ---
2787+
2788+
2789+
@pytest.mark.anyio
2790+
async def test_a_resolver_fills_its_parameter_without_any_client_interaction():
2791+
"""A resolver that computes its value server-side injects it with no client
2792+
callbacks involved. SDK-defined resolver contract."""
2793+
mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral())
2794+
2795+
def price_of(title: str) -> int:
2796+
return 42 if title == "Dune" else 0
2797+
2798+
@mcp.tool()
2799+
async def quote(title: str, price: Annotated[int, Resolve(price_of)]) -> str:
2800+
return f"{title}: {price}"
2801+
2802+
async with Client(mcp) as client:
2803+
result = await client.call_tool("quote", {"title": "Dune"})
2804+
2805+
assert result.content == [TextContent(type="text", text="Dune: 42")]
2806+
2807+
2808+
@pytest.mark.anyio
2809+
async def test_a_resolver_may_depend_on_another_resolvers_value():
2810+
"""A resolver declares its own Resolve dependency and receives that resolver's
2811+
value before the tool body runs. SDK-defined resolver contract."""
2812+
mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral())
2813+
2814+
def base_price(title: str) -> int:
2815+
return 10 if title == "Dune" else 1
2816+
2817+
def with_tax(price: Annotated[int, Resolve(base_price)]) -> int:
2818+
return price * 2
2819+
2820+
@mcp.tool()
2821+
async def quote(title: str, total: Annotated[int, Resolve(with_tax)]) -> str:
2822+
return f"{title} costs {total}"
2823+
2824+
async with Client(mcp) as client:
2825+
result = await client.call_tool("quote", {"title": "Dune"})
2826+
2827+
assert result.content == [TextContent(type="text", text="Dune costs 20")]
2828+
2829+
2830+
@pytest.mark.anyio
2831+
async def test_a_client_supplied_value_for_a_resolved_parameter_is_discarded():
2832+
"""A value the client sends under a resolved parameter's name never reaches the
2833+
tool; the resolver's value wins. SDK-defined: resolved parameters are server-side only."""
2834+
mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral())
2835+
2836+
def price_of(title: str) -> int:
2837+
return 42
2838+
2839+
@mcp.tool()
2840+
async def quote(title: str, price: Annotated[int, Resolve(price_of)]) -> str:
2841+
return f"{title}: {price}"
2842+
2843+
async with Client(mcp) as client:
2844+
result = await client.call_tool("quote", {"title": "Dune", "price": 999})
2845+
2846+
assert result.content == [TextContent(type="text", text="Dune: 42")]
2847+
2848+
2849+
@pytest.mark.anyio
2850+
async def test_resolved_parameters_are_absent_from_the_advertised_tool_schema():
2851+
"""tools/list advertises only the model-facing parameters; resolved ones are
2852+
invisible to the client. SDK-defined: resolvers are server-side dependency injection."""
2853+
mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral())
2854+
2855+
def price_of(title: str) -> int:
2856+
return 42 # pragma: no cover - only the schema is inspected
2857+
2858+
@mcp.tool()
2859+
async def quote(title: str, price: Annotated[int, Resolve(price_of)]) -> str:
2860+
return f"{title}: {price}" # pragma: no cover - only the schema is inspected
2861+
2862+
async with Client(mcp) as client:
2863+
(advertised,) = (await client.list_tools()).tools
2864+
2865+
assert advertised.input_schema == snapshot(
2866+
{
2867+
"type": "object",
2868+
"properties": {"title": {"title": "Title", "type": "string"}},
2869+
"required": ["title"],
2870+
"title": "quoteArguments",
2871+
}
2872+
)
2873+
2874+
2875+
@pytest.mark.anyio
2876+
@pytest.mark.parametrize("mode", ["legacy", "auto"])
2877+
async def test_an_elicit_marker_asks_the_client_and_injects_the_accepted_answer(mode: Literal["legacy", "auto"]):
2878+
"""A resolver returning Elicit puts its question to the client's elicitation
2879+
callback and the tool receives the validated model, identically over the 2025
2880+
back-channel and the 2026 multi-round-trip flow. SDK-defined injection contract."""
2881+
mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral())
2882+
asked: list[str] = []
2883+
2884+
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
2885+
asked.append(params.message)
2886+
return ElicitResult(action="accept", content={"username": "octocat"})
2887+
2888+
def ask(ctx: Context) -> Elicit[Login]:
2889+
return Elicit("GitHub username?", Login)
2890+
2891+
@mcp.tool()
2892+
async def whoami(login: Annotated[Login, Resolve(ask)]) -> str:
2893+
return login.username
2894+
2895+
async with Client(mcp, mode=mode, elicitation_callback=on_elicit) as client:
2896+
result = await client.call_tool("whoami", {})
2897+
2898+
assert result.content == [TextContent(type="text", text="octocat")]
2899+
assert asked == ["GitHub username?"]
2900+
2901+
2902+
@pytest.mark.anyio
2903+
@pytest.mark.parametrize("mode", ["legacy", "auto"])
2904+
async def test_a_declined_elicitation_fails_the_call_for_a_plain_model_consumer(mode: Literal["legacy", "auto"]):
2905+
"""Declining the question aborts a tool whose consumer asked for the unwrapped
2906+
model, on either protocol era. Decline semantics are spec-defined; the abort is
2907+
the SDK's contract for plain-model consumers."""
2908+
mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral())
2909+
2910+
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
2911+
return ElicitResult(action="decline")
2912+
2913+
def ask(ctx: Context) -> Elicit[Login]:
2914+
return Elicit("GitHub username?", Login)
2915+
2916+
@mcp.tool()
2917+
async def whoami(login: Annotated[Login, Resolve(ask)]) -> str:
2918+
return login.username # pragma: no cover - the decline aborts before the body
2919+
2920+
async with Client(mcp, mode=mode, elicitation_callback=on_elicit) as client:
2921+
result = await client.call_tool("whoami", {})
2922+
2923+
assert result.is_error
2924+
assert result.content == [
2925+
TextContent(
2926+
type="text",
2927+
text=(
2928+
"Error executing tool whoami: Resolver for parameter 'login' could not resolve: elicitation was decline"
2929+
),
2930+
)
2931+
]
2932+
2933+
2934+
@pytest.mark.anyio
2935+
@pytest.mark.parametrize("mode", ["legacy", "auto"])
2936+
async def test_a_declined_elicitation_reaches_a_consumer_that_asked_for_the_outcome_union(
2937+
mode: Literal["legacy", "auto"],
2938+
):
2939+
"""Annotating the outcome union hands the tool the decline to branch on instead
2940+
of aborting the call. SDK-defined consumer-annotation contract."""
2941+
mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral())
2942+
2943+
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
2944+
return ElicitResult(action="decline")
2945+
2946+
def ask(ctx: Context) -> Elicit[Login]:
2947+
return Elicit("GitHub username?", Login)
2948+
2949+
@mcp.tool()
2950+
async def whoami(login: Annotated[ElicitationResult[Login], Resolve(ask)]) -> str:
2951+
if isinstance(login, AcceptedElicitation):
2952+
return login.data.username # pragma: no cover - declined in this test
2953+
return "anonymous"
2954+
2955+
async with Client(mcp, mode=mode, elicitation_callback=on_elicit) as client:
2956+
result = await client.call_tool("whoami", {})
2957+
2958+
assert not result.is_error
2959+
assert result.content == [TextContent(type="text", text="anonymous")]
2960+
2961+
2962+
@pytest.mark.anyio
2963+
@pytest.mark.parametrize("mode", ["legacy", "auto"])
2964+
async def test_an_undeclared_capability_refuses_the_call_instead_of_asking(mode: Literal["legacy", "auto"]):
2965+
"""A client that never declared the elicitation capability is refused with the
2966+
missing-capability protocol error rather than sent a question it cannot handle.
2967+
The egress rule is spec-mandated; the SDK applies it on both eras."""
2968+
mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral())
2969+
2970+
def ask(ctx: Context) -> Elicit[Login]:
2971+
return Elicit("GitHub username?", Login)
2972+
2973+
@mcp.tool()
2974+
async def whoami(login: Annotated[Login, Resolve(ask)]) -> str:
2975+
return login.username # pragma: no cover - the gate refuses before the body
2976+
2977+
async with Client(mcp, mode=mode) as client:
2978+
with pytest.raises(MCPError) as exc_info:
2979+
await client.call_tool("whoami", {})
2980+
2981+
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
2982+
assert exc_info.value.error.data == {"requiredCapabilities": {"elicitation": {"form": {}}}}

0 commit comments

Comments
 (0)