Add resolver dependency injection for MCPServer tools (#2969)
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
This commit is contained in:
parent
4b519782f1
commit
f664db8952
5 changed files with 1844 additions and 15 deletions
129
tests/docs_src/test_dependencies.py
Normal file
129
tests/docs_src/test_dependencies.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"""`docs/tutorial/dependencies.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import ElicitRequestParams, ElicitResult, TextContent
|
||||
|
||||
from docs_src.dependencies import tutorial001, tutorial002, tutorial003
|
||||
from mcp import Client
|
||||
from mcp.client import ClientRequestContext
|
||||
|
||||
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
|
||||
|
||||
|
||||
async def test_the_resolver_fills_the_parameter_from_the_tools_own_argument() -> None:
|
||||
"""tutorial001: `check_stock` receives `title` by name and its return value becomes `stock`."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
in_stock = await client.call_tool("reserve_book", {"title": "Dune"})
|
||||
sold_out = await client.call_tool("reserve_book", {"title": "Neuromancer"})
|
||||
|
||||
assert in_stock.content == [TextContent(type="text", text="Reserved 'Dune' (6 copies left).")]
|
||||
assert sold_out.content == [TextContent(type="text", text="'Neuromancer' is out of stock.")]
|
||||
|
||||
|
||||
async def test_the_resolved_parameter_is_invisible_to_the_model() -> None:
|
||||
"""tutorial001: the input schema shown on the page is exactly what `tools/list` reports."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
(tool,) = (await client.list_tools()).tools
|
||||
|
||||
assert tool.input_schema == snapshot(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"title": {"title": "Title", "type": "string"}},
|
||||
"required": ["title"],
|
||||
"title": "reserve_bookArguments",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def test_a_client_supplied_value_for_a_resolved_parameter_is_ignored() -> None:
|
||||
"""tutorial001: the resolver's value is the only one the tool can receive."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
result = await client.call_tool("reserve_book", {"title": "Dune", "stock": {"title": "Dune", "copies": 999}})
|
||||
|
||||
assert result.content == [TextContent(type="text", text="Reserved 'Dune' (6 copies left).")]
|
||||
|
||||
|
||||
async def test_a_resolver_can_depend_on_another_resolver() -> None:
|
||||
"""tutorial002: `estimate_delivery` consumes `check_stock`'s result, and the tool gets both."""
|
||||
async with Client(tutorial002.mcp) as client:
|
||||
in_stock = await client.call_tool("order_book", {"title": "Dune"})
|
||||
backorder = await client.call_tool("order_book", {"title": "Neuromancer"})
|
||||
|
||||
assert in_stock.content == [TextContent(type="text", text="Ordered 'Dune'; it arrives tomorrow.")]
|
||||
assert backorder.content == [
|
||||
TextContent(type="text", text="'Neuromancer' is on backorder; it would arrive in 2-3 weeks.")
|
||||
]
|
||||
|
||||
|
||||
async def test_a_shared_dependency_runs_once_per_call(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""tutorial002: `stock` and `delivery` both need `check_stock`; one call, one inventory lookup."""
|
||||
|
||||
class CountingInventory:
|
||||
def __init__(self, data: dict[str, int]) -> None:
|
||||
self.data = data
|
||||
self.lookups: list[str] = []
|
||||
|
||||
def get(self, key: str, default: int) -> int:
|
||||
self.lookups.append(key)
|
||||
return self.data.get(key, default)
|
||||
|
||||
inventory = CountingInventory(dict(tutorial002.INVENTORY))
|
||||
monkeypatch.setattr(tutorial002, "INVENTORY", inventory)
|
||||
|
||||
async with Client(tutorial002.mcp) as client:
|
||||
await client.call_tool("order_book", {"title": "Dune"})
|
||||
assert inventory.lookups == ["Dune"]
|
||||
# Memoization is per call, not per server: the next call looks the title up again.
|
||||
await client.call_tool("order_book", {"title": "Dune"})
|
||||
assert inventory.lookups == ["Dune", "Dune"]
|
||||
|
||||
|
||||
async def test_an_in_stock_order_asks_no_question() -> None:
|
||||
"""tutorial003: `confirm_backorder` returns directly when stock exists - no round-trip."""
|
||||
|
||||
async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover
|
||||
raise AssertionError("an in-stock order must not elicit")
|
||||
|
||||
async with Client(tutorial003.mcp, mode="legacy", elicitation_callback=never) as client:
|
||||
result = await client.call_tool("order_book", {"title": "Dune"})
|
||||
|
||||
assert result.content == [TextContent(type="text", text="Ordered 'Dune'.")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("confirm", "expected"),
|
||||
[
|
||||
(True, "Backordered 'Neuromancer'; it ships in 2-3 weeks."),
|
||||
(False, "No order placed."),
|
||||
],
|
||||
)
|
||||
async def test_an_out_of_stock_order_asks_and_honours_the_answer(confirm: bool, expected: str) -> None:
|
||||
"""tutorial003: the resolver elicits, the SDK validates the answer, the tool reads it."""
|
||||
asked: list[str] = []
|
||||
|
||||
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
|
||||
asked.append(params.message)
|
||||
return ElicitResult(action="accept", content={"confirm": confirm})
|
||||
|
||||
async with Client(tutorial003.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
|
||||
result = await client.call_tool("order_book", {"title": "Neuromancer"})
|
||||
|
||||
assert result.content == [TextContent(type="text", text=expected)]
|
||||
assert asked == ["'Neuromancer' is out of stock (2-3 weeks). Order anyway?"]
|
||||
|
||||
|
||||
async def test_declining_an_unwrapped_dependency_aborts_the_call() -> None:
|
||||
"""tutorial003: no answer, no order - the error text on the page is the real one."""
|
||||
|
||||
async def decline(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
|
||||
return ElicitResult(action="decline")
|
||||
|
||||
async with Client(tutorial003.mcp, mode="legacy", elicitation_callback=decline) as client:
|
||||
result = await client.call_tool("order_book", {"title": "Neuromancer"})
|
||||
|
||||
assert result.is_error
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == (
|
||||
"Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline"
|
||||
)
|
||||
|
|
@ -14,7 +14,7 @@ from mcp_types import (
|
|||
)
|
||||
from pydantic import BaseModel
|
||||
|
||||
from docs_src.elicitation import tutorial001, tutorial002, tutorial003
|
||||
from docs_src.elicitation import tutorial001, tutorial002, tutorial003, tutorial004
|
||||
from mcp import Client, MCPError
|
||||
from mcp.client import ClientRequestContext
|
||||
from mcp.server import MCPServer
|
||||
|
|
@ -246,3 +246,54 @@ async def test_a_client_without_the_callback_cannot_be_asked() -> None:
|
|||
async with Client(tutorial001.mcp, mode="legacy") as client:
|
||||
with pytest.raises(MCPError, match="Elicitation not supported"):
|
||||
await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
|
||||
|
||||
|
||||
async def test_resolver_asks_only_when_the_folder_is_not_empty() -> None:
|
||||
"""tutorial004: `confirm_delete` resolves an empty folder directly and elicits otherwise."""
|
||||
tutorial004._FOLDERS.update({"/tmp/empty": [], "/tmp/project": ["main.py", "README.md"]})
|
||||
asked: list[str] = []
|
||||
|
||||
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
|
||||
assert isinstance(params, ElicitRequestFormParams)
|
||||
asked.append(params.message)
|
||||
return ElicitResult(action="accept", content={"ok": True})
|
||||
|
||||
async with Client(tutorial004.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
|
||||
empty = await client.call_tool("delete_folder", {"path": "/tmp/empty"})
|
||||
non_empty = await client.call_tool("delete_folder", {"path": "/tmp/project"})
|
||||
|
||||
assert empty.content == [TextContent(type="text", text="deleted /tmp/empty")]
|
||||
assert non_empty.content == [TextContent(type="text", text="deleted /tmp/project")]
|
||||
assert asked == ["/tmp/project has 2 file(s). Delete anyway?"] # the empty folder was not queried
|
||||
|
||||
|
||||
async def test_the_resolved_parameter_is_hidden_from_the_tool_schema() -> None:
|
||||
"""tutorial004: the `Resolve`-filled parameter never appears in the client-facing input schema."""
|
||||
async with Client(tutorial004.mcp, mode="legacy") as client:
|
||||
(tool,) = (await client.list_tools()).tools
|
||||
assert tool.name == "delete_folder"
|
||||
assert set(tool.input_schema["properties"]) == {"path"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("action", "content", "expected"),
|
||||
[
|
||||
("accept", {"ok": False}, "kept the folder"),
|
||||
("decline", None, "declined: folder not deleted"),
|
||||
("cancel", None, "cancelled: folder not deleted"),
|
||||
],
|
||||
)
|
||||
async def test_the_tool_branches_on_every_elicitation_outcome(
|
||||
action: Literal["accept", "decline", "cancel"],
|
||||
content: dict[str, str | int | float | bool | list[str] | None] | None,
|
||||
expected: str,
|
||||
) -> None:
|
||||
"""tutorial004: annotating the result union lets the tool handle accept/decline/cancel."""
|
||||
tutorial004._FOLDERS["/tmp/project"] = ["main.py", "README.md"]
|
||||
|
||||
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
|
||||
return ElicitResult(action=action, content=content)
|
||||
|
||||
async with Client(tutorial004.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
|
||||
result = await client.call_tool("delete_folder", {"path": "/tmp/project"})
|
||||
assert result.content == [TextContent(type="text", text=expected)]
|
||||
|
|
|
|||
|
|
@ -155,6 +155,28 @@ async def test_complex_function_runtime_arg_validation_with_json():
|
|||
assert result == "ok!"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_call_fn_does_not_mutate_pre_validated():
|
||||
"""A caller-provided `pre_validated` dict must not be mutated by the call."""
|
||||
|
||||
def fn(x: int, ctx: str) -> str:
|
||||
return f"{x}:{ctx}"
|
||||
|
||||
meta = func_metadata(fn, skip_names=["ctx"])
|
||||
pre_validated = meta.validate_arguments({"x": 1})
|
||||
snapshot = dict(pre_validated)
|
||||
|
||||
result = await meta.call_fn_with_arg_validation(
|
||||
fn,
|
||||
fn_is_async=False,
|
||||
arguments_to_validate={"x": 1},
|
||||
arguments_to_pass_directly={"ctx": "injected"},
|
||||
pre_validated=pre_validated,
|
||||
)
|
||||
assert result == "1:injected"
|
||||
assert pre_validated == snapshot # `ctx` was not leaked into the caller's dict
|
||||
|
||||
|
||||
def test_str_vs_list_str():
|
||||
"""Test handling of string vs list[str] type annotations.
|
||||
|
||||
|
|
|
|||
571
tests/server/mcpserver/test_resolve.py
Normal file
571
tests/server/mcpserver/test_resolve.py
Normal file
|
|
@ -0,0 +1,571 @@
|
|||
"""Tests for resolver dependency injection (MRTR) on MCPServer tools."""
|
||||
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
import pytest
|
||||
from mcp_types import ElicitRequestParams, ElicitResult, TextContent
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from mcp import Client
|
||||
from mcp.client import ClientRequestContext
|
||||
from mcp.server.mcpserver import (
|
||||
AcceptedElicitation,
|
||||
CancelledElicitation,
|
||||
Context,
|
||||
DeclinedElicitation,
|
||||
Elicit,
|
||||
ElicitationResult,
|
||||
MCPServer,
|
||||
Resolve,
|
||||
)
|
||||
from mcp.server.mcpserver.exceptions import InvalidSignature
|
||||
from mcp.server.mcpserver.resolve import _resolver_key, find_resolved_parameters
|
||||
from mcp.server.mcpserver.tools.base import Tool
|
||||
|
||||
|
||||
class Login(BaseModel):
|
||||
username: str
|
||||
|
||||
|
||||
class Confirm(BaseModel):
|
||||
ok: bool
|
||||
|
||||
|
||||
async def _alias_login(ctx: Context) -> Login:
|
||||
return Login(username="x") # pragma: no cover - only the signature is inspected
|
||||
|
||||
|
||||
def _accept(content: dict[str, str | int | float | bool | list[str] | None]):
|
||||
async def callback(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
|
||||
return ElicitResult(action="accept", content=content)
|
||||
|
||||
return callback
|
||||
|
||||
|
||||
async def _decline(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
|
||||
return ElicitResult(action="decline")
|
||||
|
||||
|
||||
async def _text(client: Client, tool: str, args: dict[str, object]) -> str:
|
||||
result = await client.call_tool(tool, args)
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
return result.content[0].text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_resolver_returns_value_directly_without_eliciting():
|
||||
mcp = MCPServer(name="Direct")
|
||||
|
||||
async def login(ctx: Context) -> Login | Elicit[Login]:
|
||||
username = (ctx.headers or {}).get("x-github-user")
|
||||
if username: # pragma: no cover - no headers on in-memory transport
|
||||
return Login(username=username)
|
||||
return Login(username="from-resolver")
|
||||
|
||||
@mcp.tool()
|
||||
async def whoami(login: Annotated[Login, Resolve(login)]) -> str:
|
||||
return login.username
|
||||
|
||||
async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover
|
||||
raise AssertionError("should not elicit")
|
||||
|
||||
async with Client(mcp, mode="legacy", elicitation_callback=never) as client:
|
||||
assert await _text(client, "whoami", {}) == "from-resolver"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_resolver_elicits_and_injects_unwrapped_model_on_accept():
|
||||
mcp = MCPServer(name="Accept")
|
||||
|
||||
async def login(ctx: Context) -> Login | Elicit[Login]:
|
||||
return Elicit("GitHub username?", Login)
|
||||
|
||||
@mcp.tool()
|
||||
async def whoami(login: Annotated[Login, Resolve(login)]) -> str:
|
||||
return login.username
|
||||
|
||||
async with Client(mcp, mode="legacy", elicitation_callback=_accept({"username": "octocat"})) as client:
|
||||
assert await _text(client, "whoami", {}) == "octocat"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_consumer_receives_result_union_and_branches():
|
||||
mcp = MCPServer(name="Union")
|
||||
|
||||
async def login(ctx: Context) -> Login | Elicit[Login]:
|
||||
return Elicit("GitHub username?", Login)
|
||||
|
||||
@mcp.tool()
|
||||
async def whoami(login: Annotated[ElicitationResult[Login], Resolve(login)]) -> str:
|
||||
match login:
|
||||
case AcceptedElicitation(data=data):
|
||||
return f"hi {data.username}"
|
||||
case _: # pragma: no cover - accepted in this test
|
||||
return "no username"
|
||||
|
||||
async with Client(mcp, mode="legacy", elicitation_callback=_accept({"username": "octocat"})) as client:
|
||||
assert await _text(client, "whoami", {}) == "hi octocat"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_decline_reaches_union_consumer_without_aborting():
|
||||
mcp = MCPServer(name="UnionDecline")
|
||||
|
||||
async def login(ctx: Context) -> Login | Elicit[Login]:
|
||||
return Elicit("GitHub username?", Login)
|
||||
|
||||
@mcp.tool()
|
||||
async def whoami(
|
||||
login: Annotated[AcceptedElicitation[Login] | DeclinedElicitation | CancelledElicitation, Resolve(login)],
|
||||
) -> str:
|
||||
if isinstance(login, DeclinedElicitation):
|
||||
return "declined gracefully"
|
||||
raise NotImplementedError
|
||||
|
||||
async with Client(mcp, mode="legacy", elicitation_callback=_decline) as client:
|
||||
assert await _text(client, "whoami", {}) == "declined gracefully"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_decline_aborts_when_consumer_wants_unwrapped():
|
||||
mcp = MCPServer(name="UnwrappedDecline")
|
||||
|
||||
async def login(ctx: Context) -> Login | Elicit[Login]:
|
||||
return Elicit("GitHub username?", Login)
|
||||
|
||||
@mcp.tool()
|
||||
async def whoami(login: Annotated[Login, Resolve(login)]) -> str:
|
||||
raise NotImplementedError # pragma: no cover - never reached
|
||||
|
||||
async with Client(mcp, mode="legacy", elicitation_callback=_decline) as client:
|
||||
result = await client.call_tool("whoami", {})
|
||||
assert result.is_error
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert "decline" in result.content[0].text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_nested_resolver_sees_dependency_and_tool_args():
|
||||
mcp = MCPServer(name="Nested")
|
||||
|
||||
async def login(ctx: Context) -> Login | Elicit[Login]:
|
||||
return Elicit("GitHub username?", Login)
|
||||
|
||||
async def confirm(repo: str, login: Annotated[Login, Resolve(login)]) -> Elicit[Confirm]:
|
||||
return Elicit(f"Star {repo} as {login.username}?", Confirm)
|
||||
|
||||
@mcp.tool()
|
||||
async def star_repo(
|
||||
repo: str,
|
||||
login: Annotated[Login, Resolve(login)],
|
||||
confirm: Annotated[Confirm, Resolve(confirm)],
|
||||
) -> str:
|
||||
if confirm.ok:
|
||||
return f"starred {repo} as {login.username}"
|
||||
raise NotImplementedError
|
||||
|
||||
async def callback(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
|
||||
if "username" in params.message:
|
||||
return ElicitResult(action="accept", content={"username": "octocat"})
|
||||
assert "Star modelcontextprotocol/python-sdk as octocat?" in params.message
|
||||
return ElicitResult(action="accept", content={"ok": True})
|
||||
|
||||
async with Client(mcp, mode="legacy", elicitation_callback=callback) as client:
|
||||
text = await _text(client, "star_repo", {"repo": "modelcontextprotocol/python-sdk"})
|
||||
assert text == "starred modelcontextprotocol/python-sdk as octocat"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_resolver_runs_once_for_two_consumers():
|
||||
mcp = MCPServer(name="ExactlyOnce")
|
||||
elicit_count = 0
|
||||
|
||||
async def login(ctx: Context) -> Login | Elicit[Login]:
|
||||
return Elicit("GitHub username?", Login)
|
||||
|
||||
async def confirm(login: Annotated[Login, Resolve(login)]) -> Elicit[Confirm]:
|
||||
return Elicit(f"As {login.username}?", Confirm)
|
||||
|
||||
@mcp.tool()
|
||||
async def star_repo(
|
||||
login: Annotated[Login, Resolve(login)],
|
||||
confirm: Annotated[Confirm, Resolve(confirm)],
|
||||
) -> str:
|
||||
return f"{login.username}:{confirm.ok}"
|
||||
|
||||
async def callback(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
|
||||
nonlocal elicit_count
|
||||
if "username" in params.message:
|
||||
elicit_count += 1
|
||||
return ElicitResult(action="accept", content={"username": "octocat"})
|
||||
return ElicitResult(action="accept", content={"ok": True})
|
||||
|
||||
async with Client(mcp, mode="legacy", elicitation_callback=callback) as client:
|
||||
assert await _text(client, "star_repo", {}) == "octocat:True"
|
||||
assert elicit_count == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_sync_resolver():
|
||||
mcp = MCPServer(name="Sync")
|
||||
|
||||
def login(ctx: Context) -> Login:
|
||||
return Login(username="sync-user")
|
||||
|
||||
@mcp.tool()
|
||||
async def whoami(login: Annotated[Login, Resolve(login)]) -> str:
|
||||
return login.username
|
||||
|
||||
async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover
|
||||
raise AssertionError("should not elicit")
|
||||
|
||||
async with Client(mcp, mode="legacy", elicitation_callback=never) as client:
|
||||
assert await _text(client, "whoami", {}) == "sync-user"
|
||||
|
||||
|
||||
def test_resolved_params_absent_from_input_schema():
|
||||
async def login(ctx: Context) -> Login:
|
||||
return Login(username="x") # pragma: no cover - only the schema is inspected
|
||||
|
||||
async def tool(
|
||||
repo: Annotated[str, Field(description="repo name")],
|
||||
login: Annotated[Login, Resolve(login)],
|
||||
) -> str:
|
||||
return repo # pragma: no cover - only the schema is inspected
|
||||
|
||||
built = Tool.from_function(tool)
|
||||
properties = built.parameters["properties"]
|
||||
assert "repo" in properties
|
||||
assert "login" not in properties
|
||||
|
||||
|
||||
def test_cycle_detection_raises_at_registration():
|
||||
async def a(dep: Login) -> Login:
|
||||
return dep # pragma: no cover
|
||||
|
||||
async def b(dep: Login) -> Login:
|
||||
return dep # pragma: no cover
|
||||
|
||||
# Close the loop after both exist: a depends on b, b depends on a.
|
||||
a.__annotations__["dep"] = Annotated[Login, Resolve(b)]
|
||||
b.__annotations__["dep"] = Annotated[Login, Resolve(a)]
|
||||
|
||||
async def tool(value: Annotated[Login, Resolve(a)]) -> str:
|
||||
return value.username # pragma: no cover
|
||||
|
||||
with pytest.raises(InvalidSignature, match="cyclic"):
|
||||
Tool.from_function(tool)
|
||||
|
||||
|
||||
def test_find_resolved_parameters_tolerates_unresolvable_hints():
|
||||
def fn(x: int) -> int:
|
||||
return x # pragma: no cover
|
||||
|
||||
fn.__annotations__["x"] = "DoesNotExist"
|
||||
assert find_resolved_parameters(fn) == {}
|
||||
|
||||
|
||||
def test_elicitation_result_alias_resolves_under_postponed_annotations():
|
||||
# Reproduces the case where `from __future__ import annotations` stringifies
|
||||
# `Annotated[ElicitationResult[Login], Resolve(_alias_login)]`: the alias must be
|
||||
# subscriptable so the resolver is detected (not silently dropped) and the
|
||||
# consumer is recognized as wanting the result union.
|
||||
def tool(login: str) -> str:
|
||||
return login # pragma: no cover
|
||||
|
||||
tool.__annotations__["login"] = "Annotated[ElicitationResult[Login], Resolve(_alias_login)]"
|
||||
resolved = find_resolved_parameters(tool)
|
||||
assert "login" in resolved
|
||||
assert resolved["login"][1] is True # wants_union
|
||||
|
||||
|
||||
def test_unresolvable_resolver_param_raises_at_registration():
|
||||
async def login(mystery: int) -> Login:
|
||||
return Login(username="x") # pragma: no cover
|
||||
|
||||
async def tool(login: Annotated[Login, Resolve(login)]) -> str:
|
||||
return login.username # pragma: no cover
|
||||
|
||||
with pytest.raises(InvalidSignature, match="cannot be resolved"):
|
||||
Tool.from_function(tool)
|
||||
|
||||
|
||||
def test_resolve_marker_inside_a_union_raises_at_registration():
|
||||
async def login(ctx: Context) -> Login:
|
||||
return Login(username="x") # pragma: no cover
|
||||
|
||||
async def tool(login: Annotated[Login, Resolve(login)] | None = None) -> str:
|
||||
return login.username if login else "" # pragma: no cover
|
||||
|
||||
with pytest.raises(InvalidSignature, match="wraps `Resolve"):
|
||||
Tool.from_function(tool)
|
||||
|
||||
|
||||
def test_bare_elicitation_result_alias_wants_the_outcome_union():
|
||||
# The bare `ElicitationResult` alias (no `[T]` subscription) must still opt into
|
||||
# the result union, not be treated as wanting the unwrapped model.
|
||||
async def login(ctx: Context) -> Login:
|
||||
return Login(username="x") # pragma: no cover
|
||||
|
||||
async def tool(login: object) -> str:
|
||||
return "x" # pragma: no cover
|
||||
|
||||
bare_alias: Any = ElicitationResult
|
||||
tool.__annotations__["login"] = Annotated[bare_alias, Resolve(login)]
|
||||
(_, wants_union) = find_resolved_parameters(tool)["login"]
|
||||
assert wants_union is True
|
||||
|
||||
|
||||
def test_resolve_marker_on_return_annotation_is_ignored():
|
||||
async def login(ctx: Context) -> Login:
|
||||
return Login(username="x") # pragma: no cover
|
||||
|
||||
async def tool(repo: str) -> Annotated[str, Resolve(login)]:
|
||||
return repo # pragma: no cover
|
||||
|
||||
assert find_resolved_parameters(tool) == {}
|
||||
|
||||
|
||||
def test_callable_object_resolver_error_uses_type_name():
|
||||
class BadResolver:
|
||||
async def __call__(self, mystery: int) -> Login:
|
||||
return Login(username="x") # pragma: no cover
|
||||
|
||||
async def tool(login: Annotated[Login, Resolve(BadResolver())]) -> str:
|
||||
return login.username # pragma: no cover
|
||||
|
||||
with pytest.raises(InvalidSignature, match="'BadResolver'"):
|
||||
Tool.from_function(tool)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_by_name_resolver_param_uses_aliased_tool_arg():
|
||||
mcp = MCPServer(name="Aliased")
|
||||
|
||||
# `schema` collides with a BaseModel attribute, so func_metadata aliases the field;
|
||||
# the runtime kwarg key is the alias, which is what a by-name resolver must match.
|
||||
async def upper(schema: str) -> Login:
|
||||
return Login(username=schema.upper())
|
||||
|
||||
@mcp.tool()
|
||||
async def run(schema: str, shouted: Annotated[Login, Resolve(upper)]) -> str:
|
||||
return shouted.username
|
||||
|
||||
async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover
|
||||
raise AssertionError("should not elicit")
|
||||
|
||||
async with Client(mcp, mode="legacy", elicitation_callback=never) as client:
|
||||
assert await _text(client, "run", {"schema": "gpt"}) == "GPT"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_resolver_may_return_non_basemodel_value():
|
||||
mcp = MCPServer(name="NonModel")
|
||||
|
||||
async def get_token(ctx: Context) -> str:
|
||||
return "secret-token"
|
||||
|
||||
@mcp.tool()
|
||||
async def use_token(token: Annotated[str, Resolve(get_token)]) -> str:
|
||||
return token
|
||||
|
||||
async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover
|
||||
raise AssertionError("should not elicit")
|
||||
|
||||
async with Client(mcp, mode="legacy", elicitation_callback=never) as client:
|
||||
assert await _text(client, "use_token", {}) == "secret-token"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_resolver_accepts_optional_context_annotation():
|
||||
mcp = MCPServer(name="OptionalContext")
|
||||
|
||||
async def whoami(ctx: Context | None) -> str:
|
||||
assert ctx is not None
|
||||
return "has-context"
|
||||
|
||||
@mcp.tool()
|
||||
async def run(who: Annotated[str, Resolve(whoami)]) -> str:
|
||||
return who
|
||||
|
||||
async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover
|
||||
raise AssertionError("should not elicit")
|
||||
|
||||
async with Client(mcp, mode="legacy", elicitation_callback=never) as client:
|
||||
assert await _text(client, "run", {}) == "has-context"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_bound_method_resolver_runs_once_across_references():
|
||||
mcp = MCPServer(name="BoundMethod")
|
||||
calls = 0
|
||||
|
||||
class Service:
|
||||
async def token(self, ctx: Context) -> str:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return "tok"
|
||||
|
||||
service = Service()
|
||||
|
||||
# Each `service.token` access is a fresh bound-method object; keying by the
|
||||
# callable (not id) keeps the resolver memoized to a single call.
|
||||
async def downstream(token: Annotated[str, Resolve(service.token)]) -> str:
|
||||
return token.upper()
|
||||
|
||||
@mcp.tool()
|
||||
async def run(
|
||||
token: Annotated[str, Resolve(service.token)],
|
||||
shouted: Annotated[str, Resolve(downstream)],
|
||||
) -> str:
|
||||
return f"{token}:{shouted}"
|
||||
|
||||
async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover
|
||||
raise AssertionError("should not elicit")
|
||||
|
||||
async with Client(mcp, mode="legacy", elicitation_callback=never) as client:
|
||||
assert await _text(client, "run", {}) == "tok:TOK"
|
||||
assert calls == 1
|
||||
|
||||
|
||||
def test_bound_method_cycle_is_detected():
|
||||
class Service:
|
||||
async def a(self, dep: Login) -> Login:
|
||||
return dep # pragma: no cover
|
||||
|
||||
async def b(self, dep: Login) -> Login:
|
||||
return dep # pragma: no cover
|
||||
|
||||
service = Service()
|
||||
service.a.__func__.__annotations__["dep"] = Annotated[Login, Resolve(service.b)]
|
||||
service.b.__func__.__annotations__["dep"] = Annotated[Login, Resolve(service.a)]
|
||||
|
||||
async def tool(value: Annotated[Login, Resolve(service.a)]) -> str:
|
||||
return value.username # pragma: no cover
|
||||
|
||||
with pytest.raises(InvalidSignature, match="cyclic"):
|
||||
Tool.from_function(tool)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_resolver_and_body_see_the_same_validated_default():
|
||||
mcp = MCPServer(name="DefaultFactory")
|
||||
counter = {"n": 0}
|
||||
|
||||
def next_id() -> int:
|
||||
counter["n"] += 1
|
||||
return counter["n"]
|
||||
|
||||
# A by-name resolver and the tool body must observe one validation pass, so the
|
||||
# `default_factory` runs once and both see the same generated value.
|
||||
async def echo_id(request_id: int) -> int:
|
||||
return request_id
|
||||
|
||||
@mcp.tool()
|
||||
async def run(
|
||||
request_id: Annotated[int, Field(default_factory=next_id)],
|
||||
resolved_id: Annotated[int, Resolve(echo_id)],
|
||||
) -> str:
|
||||
return f"{request_id}:{resolved_id}"
|
||||
|
||||
async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover
|
||||
raise AssertionError("should not elicit")
|
||||
|
||||
async with Client(mcp, mode="legacy", elicitation_callback=never) as client:
|
||||
assert await _text(client, "run", {}) == "1:1"
|
||||
assert counter["n"] == 1
|
||||
|
||||
|
||||
def test_resolver_key_is_stable_for_methods_and_distinct_callables():
|
||||
class Service:
|
||||
def handler(self) -> None: ... # pragma: no cover
|
||||
|
||||
a, b = Service(), Service()
|
||||
|
||||
# Pure-python bound methods: stable across accesses, distinct per instance.
|
||||
assert _resolver_key(a.handler) == _resolver_key(a.handler)
|
||||
assert _resolver_key(a.handler) != _resolver_key(b.handler)
|
||||
|
||||
# Built-in bound methods (no `__func__`): fresh object each access, but the key
|
||||
# is stable and keyed to `__self__`.
|
||||
items: list[int] = []
|
||||
others: list[int] = []
|
||||
assert _resolver_key(items.append) == _resolver_key(items.append)
|
||||
assert _resolver_key(items.append) != _resolver_key(others.append)
|
||||
assert _resolver_key(items.append) != _resolver_key(items.pop)
|
||||
|
||||
# Plain functions key by identity.
|
||||
def fn() -> None: ... # pragma: no cover
|
||||
|
||||
assert _resolver_key(fn) == _resolver_key(fn)
|
||||
|
||||
|
||||
def _delete_folder_server() -> tuple[MCPServer, dict[str, list[str]]]:
|
||||
"""The `delete_folder` example from docs/migration.md, wired to an in-memory fs."""
|
||||
mcp = MCPServer(name="files")
|
||||
fs: dict[str, list[str]] = {}
|
||||
|
||||
async def confirm_delete(path: str) -> Confirm | Elicit[Confirm]:
|
||||
file_count = len(fs.get(path, []))
|
||||
if file_count == 0:
|
||||
return Confirm(ok=True)
|
||||
return Elicit(f"{path} has {file_count} file(s). Delete anyway?", Confirm)
|
||||
|
||||
@mcp.tool()
|
||||
async def delete_folder(
|
||||
path: str,
|
||||
confirm: Annotated[ElicitationResult[Confirm], Resolve(confirm_delete)],
|
||||
) -> str:
|
||||
match confirm:
|
||||
case AcceptedElicitation(data=Confirm(ok=True)):
|
||||
fs.pop(path, None)
|
||||
return f"deleted {path}"
|
||||
case AcceptedElicitation():
|
||||
return "kept the folder"
|
||||
case DeclinedElicitation():
|
||||
return "declined: folder not deleted"
|
||||
case CancelledElicitation(): # pragma: no branch
|
||||
return "cancelled: folder not deleted"
|
||||
|
||||
return mcp, fs
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_empty_folder_does_not_elicit():
|
||||
mcp, fs = _delete_folder_server()
|
||||
fs["/empty"] = []
|
||||
|
||||
async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover
|
||||
raise AssertionError("should not elicit for an empty folder")
|
||||
|
||||
async with Client(mcp, mode="legacy", elicitation_callback=never) as client:
|
||||
assert await _text(client, "delete_folder", {"path": "/empty"}) == "deleted /empty"
|
||||
assert "/empty" not in fs
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
("action", "content", "expected"),
|
||||
[
|
||||
("accept", {"ok": True}, "deleted /docs"),
|
||||
("accept", {"ok": False}, "kept the folder"),
|
||||
("decline", None, "declined: folder not deleted"),
|
||||
("cancel", None, "cancelled: folder not deleted"),
|
||||
],
|
||||
)
|
||||
async def test_delete_non_empty_folder_handles_every_outcome(
|
||||
action: Literal["accept", "decline", "cancel"],
|
||||
content: dict[str, str | int | float | bool | list[str] | None] | None,
|
||||
expected: str,
|
||||
):
|
||||
mcp, fs = _delete_folder_server()
|
||||
fs["/docs"] = ["a.txt", "b.txt"]
|
||||
|
||||
async def callback(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
|
||||
assert "/docs has 2 file(s)" in params.message
|
||||
return ElicitResult(action=action, content=content)
|
||||
|
||||
async with Client(mcp, mode="legacy", elicitation_callback=callback) as client:
|
||||
assert await _text(client, "delete_folder", {"path": "/docs"}) == expected
|
||||
assert ("/docs" in fs) is (expected != "deleted /docs")
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import base64
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
|
@ -1801,6 +1802,33 @@ async def test_report_progress_delegates_to_session_report_progress():
|
|||
mock_session.report_progress.assert_awaited_once_with(50, 100, "halfway")
|
||||
|
||||
|
||||
def _request_context(request: object | None) -> ServerRequestContext[None, object]:
|
||||
return ServerRequestContext(
|
||||
session=AsyncMock(),
|
||||
method="tools/call",
|
||||
lifespan_context=None,
|
||||
protocol_version="2025-11-25",
|
||||
request=request,
|
||||
)
|
||||
|
||||
|
||||
def test_context_headers_returns_request_headers():
|
||||
request = SimpleNamespace(headers={"x-github-user": "octocat"})
|
||||
ctx = Context(request_context=_request_context(request), mcp_server=MagicMock())
|
||||
assert ctx.headers == {"x-github-user": "octocat"}
|
||||
|
||||
|
||||
def test_context_headers_is_none_without_request():
|
||||
ctx = Context(request_context=_request_context(None), mcp_server=MagicMock())
|
||||
assert ctx.headers is None
|
||||
|
||||
|
||||
def test_context_headers_is_none_when_request_carries_no_headers():
|
||||
"""A transport may attach a custom request object that has no headers attribute."""
|
||||
ctx = Context(request_context=_request_context(object()), mcp_server=MagicMock())
|
||||
assert ctx.headers is None
|
||||
|
||||
|
||||
async def test_read_resource_template_error():
|
||||
"""Template-creation failure must surface as INTERNAL_ERROR, not INVALID_PARAMS (not-found)."""
|
||||
mcp = MCPServer()
|
||||
|
|
|
|||
Loading…
Reference in a new issue