Skip to content

Commit 8f677c9

Browse files
committed
Switch resolver docs/example to a delete-folder confirmation flow
Replace the GitHub star example with a delete_folder tool: the confirm_delete resolver lists the folder by reading the tool's own path argument and only elicits when the folder is non-empty (an empty folder resolves to ok=True with no round-trip). delete_folder annotates ElicitationResult[Confirm] and handles every outcome - accept-and-delete, accept-but-keep, decline, and cancel. Add end-to-end tests covering all five paths; the cancel path now exercises elicit_with_validation's cancel branch (pragma removed).
1 parent b0424da commit 8f677c9

3 files changed

Lines changed: 99 additions & 39 deletions

File tree

docs/migration.md

Lines changed: 27 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1462,7 +1462,9 @@ The lowlevel `Server` also now exposes a `session_manager` property to access th
14621462

14631463
### Resolver dependency injection for tools (`Resolve` / `Elicit`)
14641464

1465-
A tool parameter annotated `Annotated[T, Resolve(fn)]` is filled by running the resolver `fn` before the tool body, instead of by the calling LLM. Resolvers form a dependency graph: a resolver may declare its own `Resolve(...)` dependencies, read the `Context` (including `ctx.headers`), and receive the tool's own arguments by name. A resolver may return `Elicit[T]` to ask the client; the SDK runs the elicitation and injects the answer. Each resolver runs at most once per `tools/call`.
1465+
A tool parameter annotated `Annotated[T, Resolve(fn)]` is filled by running the resolver `fn` before the tool body, instead of by the calling LLM. Resolvers form a dependency graph: a resolver may declare its own `Resolve(...)` dependencies, read the `Context` (including `ctx.headers`), and receive the tool's own arguments by name. A resolver may return `Elicit[T]` to ask the client; the SDK runs the elicitation and injects the answer. A resolver only elicits when it needs to - it can also resolve a value directly and skip the question. Each resolver runs at most once per `tools/call`.
1466+
1467+
The injected type follows the consumer's annotation. Annotating the unwrapped model (`Annotated[Confirm, Resolve(confirm)]`) injects the model on accept and aborts the call with an error result on decline or cancel. To branch on the outcome instead - so the tool can react to decline and cancel - annotate `ElicitationResult[Confirm]` (or an explicit `AcceptedElicitation[Confirm] | DeclinedElicitation | CancelledElicitation` union):
14661468

14671469
```python
14681470
from typing import Annotated
@@ -1472,58 +1474,46 @@ from pydantic import BaseModel
14721474
from mcp.server.mcpserver import (
14731475
AcceptedElicitation,
14741476
CancelledElicitation,
1475-
Context,
14761477
DeclinedElicitation,
14771478
Elicit,
1479+
ElicitationResult,
14781480
MCPServer,
14791481
Resolve,
14801482
)
14811483

1482-
mcp = MCPServer(name="github")
1483-
1484-
1485-
class Login(BaseModel):
1486-
username: str
1484+
mcp = MCPServer(name="files")
14871485

14881486

14891487
class Confirm(BaseModel):
14901488
ok: bool
14911489

14921490

1493-
async def login(ctx: Context) -> Login | Elicit[Login]:
1494-
if username := (ctx.headers or {}).get("x-github-user"):
1495-
return Login(username=username) # resolved from context, no question
1496-
return Elicit("GitHub username?", Login) # must ask
1497-
1498-
1499-
async def confirm(repo: str, login: Annotated[Login, Resolve(login)]) -> Elicit[Confirm]:
1500-
return Elicit(f"Star {repo} as {login.username}?", Confirm)
1491+
async def confirm_delete(path: str) -> Confirm | Elicit[Confirm]:
1492+
file_count = len(list_files(path))
1493+
if file_count == 0:
1494+
return Confirm(ok=True) # empty folder: nothing to confirm, no question
1495+
return Elicit(f"{path} has {file_count} file(s). Delete anyway?", Confirm)
15011496

15021497

15031498
@mcp.tool()
1504-
async def star_repo(
1505-
repo: str,
1506-
login: Annotated[Login, Resolve(login)],
1507-
confirm: Annotated[Confirm, Resolve(confirm)],
1499+
async def delete_folder(
1500+
path: str,
1501+
confirm: Annotated[ElicitationResult[Confirm], Resolve(confirm_delete)],
15081502
) -> str:
1509-
"""Star a GitHub repo."""
1510-
return f"starred {repo} as {login.username}" if confirm.ok else "cancelled"
1511-
```
1512-
1513-
The injected type follows the consumer's annotation. Annotating the unwrapped model (`Annotated[Login, Resolve(login)]`) injects the model on accept and aborts the call with an error result on decline or cancel. To branch on the outcome instead, annotate `ElicitationResult[Login]` (or an explicit `AcceptedElicitation[Login] | DeclinedElicitation | CancelledElicitation` union):
1514-
1515-
```python
1516-
from mcp.server.mcpserver import ElicitationResult
1517-
1518-
1519-
@mcp.tool()
1520-
async def whoami(login: Annotated[ElicitationResult[Login], Resolve(login)]) -> str:
1521-
match login:
1522-
case AcceptedElicitation(data=data):
1523-
return f"hi {data.username}"
1524-
case _:
1525-
return "no username provided"
1526-
```
1503+
"""Delete a folder, asking for confirmation when it is not empty."""
1504+
match confirm:
1505+
case AcceptedElicitation(data=Confirm(ok=True)):
1506+
delete(path)
1507+
return f"deleted {path}"
1508+
case AcceptedElicitation():
1509+
return "kept the folder"
1510+
case DeclinedElicitation():
1511+
return "declined: folder not deleted"
1512+
case CancelledElicitation():
1513+
return "cancelled: folder not deleted"
1514+
```
1515+
1516+
The `confirm_delete` resolver reads the tool's own `path` argument by name, lists the folder, and only elicits when the folder is non-empty - an empty folder resolves to `Confirm(ok=True)` with no round-trip to the client. Because `delete_folder` annotates the result union, it handles every outcome: the user accepting and confirming, accepting but declining to delete (`ok=False`), declining the elicitation, or cancelling it.
15271517

15281518
Resolved parameters are omitted from the tool's input schema, so the client never supplies them. Resolver parameters that cannot be classified, and cyclic resolver dependencies, raise at registration time.
15291519

src/mcp/server/elicitation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ async def elicit_with_validation(
118118
return AcceptedElicitation(data=validated_data)
119119
elif result.action == "decline":
120120
return DeclinedElicitation()
121-
elif result.action == "cancel": # pragma: no cover
121+
elif result.action == "cancel":
122122
return CancelledElicitation()
123123
else: # pragma: no cover
124124
# This should never happen, but handle it just in case

tests/server/mcpserver/test_resolve.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Tests for resolver dependency injection (MRTR) on MCPServer tools."""
22

3-
from typing import Annotated
3+
from typing import Annotated, Literal
44

55
import pytest
66
from mcp_types import ElicitRequestParams, ElicitResult, TextContent
@@ -473,3 +473,73 @@ def handler(self) -> None: ... # pragma: no cover
473473
def fn() -> None: ... # pragma: no cover
474474

475475
assert _resolver_key(fn) == _resolver_key(fn)
476+
477+
478+
def _delete_folder_server() -> tuple[MCPServer, dict[str, list[str]]]:
479+
"""The `delete_folder` example from docs/migration.md, wired to an in-memory fs."""
480+
mcp = MCPServer(name="files")
481+
fs: dict[str, list[str]] = {}
482+
483+
async def confirm_delete(path: str) -> Confirm | Elicit[Confirm]:
484+
file_count = len(fs.get(path, []))
485+
if file_count == 0:
486+
return Confirm(ok=True)
487+
return Elicit(f"{path} has {file_count} file(s). Delete anyway?", Confirm)
488+
489+
@mcp.tool()
490+
async def delete_folder(
491+
path: str,
492+
confirm: Annotated[ElicitationResult[Confirm], Resolve(confirm_delete)],
493+
) -> str:
494+
match confirm:
495+
case AcceptedElicitation(data=Confirm(ok=True)):
496+
fs.pop(path, None)
497+
return f"deleted {path}"
498+
case AcceptedElicitation():
499+
return "kept the folder"
500+
case DeclinedElicitation():
501+
return "declined: folder not deleted"
502+
case CancelledElicitation(): # pragma: no branch
503+
return "cancelled: folder not deleted"
504+
505+
return mcp, fs
506+
507+
508+
@pytest.mark.anyio
509+
async def test_delete_empty_folder_does_not_elicit():
510+
mcp, fs = _delete_folder_server()
511+
fs["/empty"] = []
512+
513+
async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover
514+
raise AssertionError("should not elicit for an empty folder")
515+
516+
async with Client(mcp, mode="legacy", elicitation_callback=never) as client:
517+
assert await _text(client, "delete_folder", {"path": "/empty"}) == "deleted /empty"
518+
assert "/empty" not in fs
519+
520+
521+
@pytest.mark.anyio
522+
@pytest.mark.parametrize(
523+
("action", "content", "expected"),
524+
[
525+
("accept", {"ok": True}, "deleted /docs"),
526+
("accept", {"ok": False}, "kept the folder"),
527+
("decline", None, "declined: folder not deleted"),
528+
("cancel", None, "cancelled: folder not deleted"),
529+
],
530+
)
531+
async def test_delete_non_empty_folder_handles_every_outcome(
532+
action: Literal["accept", "decline", "cancel"],
533+
content: dict[str, str | int | float | bool | list[str] | None] | None,
534+
expected: str,
535+
):
536+
mcp, fs = _delete_folder_server()
537+
fs["/docs"] = ["a.txt", "b.txt"]
538+
539+
async def callback(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
540+
assert "/docs has 2 file(s)" in params.message
541+
return ElicitResult(action=action, content=content)
542+
543+
async with Client(mcp, mode="legacy", elicitation_callback=callback) as client:
544+
assert await _text(client, "delete_folder", {"path": "/docs"}) == expected
545+
assert ("/docs" in fs) is (expected != "deleted /docs")

0 commit comments

Comments
 (0)