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
2 changed files with 1844 additions and 15 deletions

View 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"
)

View file

@ -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)]