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
2 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)]
|
||||
|
|
|
|||
Loading…
Reference in a new issue