|
7 | 7 |
|
8 | 8 | import anyio |
9 | 9 | import pytest |
| 10 | +from inline_snapshot import snapshot |
10 | 11 | from mcp_types import ( |
11 | 12 | MISSING_REQUIRED_CLIENT_CAPABILITY, |
12 | 13 | CallToolResult, |
@@ -2780,3 +2781,202 @@ async def calc(answer: Annotated[CreateMessageResultWithTools, Resolve(_ask_with |
2780 | 2781 | sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability()), |
2781 | 2782 | ) as client: |
2782 | 2783 | 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