mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
Add tests and docs for URL elicitation
This commit is contained in:
parent
c0cc5d310a
commit
35797357fc
3 changed files with 217 additions and 0 deletions
|
|
@ -112,6 +112,34 @@ async def elicitation_handler(message, response_type, params, context):
|
|||
- **`decline`**: User chose not to provide the requested information. Omit `content`.
|
||||
- **`cancel`**: User cancelled the entire operation. Omit `content`.
|
||||
|
||||
## URL Elicitation
|
||||
|
||||
Servers can request a different kind of interaction: instead of asking the user to fill out a form, they can direct the user to visit a URL out-of-band—for OAuth consent, API-key entry, or payment. This keeps sensitive data out of the LLM context. See [URL Elicitation](/servers/elicitation#url-elicitation) on the server side for why this matters.
|
||||
|
||||
A URL elicitation is distinguishable from a form elicitation in two ways: `response_type` is `None` (there is no schema to fill out), and `params` is an `ElicitRequestURLParams` carrying the URL the user should visit. Check for it with `isinstance`, then present the URL and return an accept/decline/cancel action—no `content` is needed for any of them.
|
||||
|
||||
```python
|
||||
from fastmcp import Client
|
||||
from fastmcp.client.elicitation import ElicitResult, ElicitRequestURLParams
|
||||
|
||||
async def elicitation_handler(message, response_type, params, context):
|
||||
if isinstance(params, ElicitRequestURLParams):
|
||||
print(f"{message}\nVisit: {params.url}")
|
||||
approved = input("Open this URL? [y/N] ").lower() == "y"
|
||||
return ElicitResult(action="accept" if approved else "decline")
|
||||
|
||||
# Otherwise this is a normal form elicitation
|
||||
user_input = input(f"{message}: ")
|
||||
return response_type(value=user_input)
|
||||
|
||||
client = Client(
|
||||
"my_mcp_server.py",
|
||||
elicitation_handler=elicitation_handler,
|
||||
)
|
||||
```
|
||||
|
||||
Returning `action="accept"` signals that the user consented to navigate to the URL. The actual interaction completes out-of-band in the user's browser, so your handler never sees the secrets exchanged there.
|
||||
|
||||
## Example
|
||||
|
||||
A file management tool might ask which directory to create:
|
||||
|
|
|
|||
|
|
@ -116,6 +116,45 @@ Elicitation requires the client to implement an elicitation handler. If a client
|
|||
|
||||
See [Client Elicitation](/clients/elicitation) for details on how clients handle these requests.
|
||||
|
||||
## URL Elicitation
|
||||
|
||||
Some interactions should never pass through the model. OAuth consent screens, API-key entry, and payment flows all involve secrets or credentials that you don't want flowing through the LLM context or the MCP client's form UI. URL elicitation solves this by directing the user to visit a URL out-of-band, where the sensitive interaction happens in a real browser session. The server learns only whether the user agreed to navigate—never what they entered.
|
||||
|
||||
Call `ctx.elicit_url()` with a message explaining why the interaction is needed and the URL the user should visit. FastMCP generates a unique elicitation ID for each call automatically.
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP, Context
|
||||
from fastmcp.server.elicitation import (
|
||||
AcceptedUrlElicitation,
|
||||
DeclinedElicitation,
|
||||
CancelledElicitation,
|
||||
)
|
||||
|
||||
mcp = FastMCP("Payments Server")
|
||||
|
||||
@mcp.tool
|
||||
async def connect_account(ctx: Context) -> str:
|
||||
"""Connect the user's account via an out-of-band OAuth flow."""
|
||||
result = await ctx.elicit_url(
|
||||
message="Authorize access to your account to continue",
|
||||
url="https://example.com/oauth/authorize",
|
||||
)
|
||||
|
||||
match result:
|
||||
case AcceptedUrlElicitation():
|
||||
return "Account authorization started—complete it in your browser."
|
||||
case DeclinedElicitation():
|
||||
return "Authorization declined"
|
||||
case CancelledElicitation():
|
||||
return "Authorization cancelled"
|
||||
```
|
||||
|
||||
The result is one of `AcceptedUrlElicitation`, `DeclinedElicitation`, or `CancelledElicitation`. An accepted result means the user consented to navigate to the URL—the actual interaction (signing in, entering a key, paying) completes out-of-band in the browser, not through MCP.
|
||||
|
||||
<Note>
|
||||
URL elicitation is not yet supported from [background tasks](/servers/tasks). Calling `ctx.elicit_url()` inside a `@mcp.tool(task=True)` tool raises `NotImplementedError`; use it from a regular tool instead.
|
||||
</Note>
|
||||
|
||||
## Schema and Response Types
|
||||
|
||||
The server must send a schema to the client indicating the type of data it expects in response to the elicitation request. The MCP spec only supports a limited subset of JSON Schema types for elicitation responses—specifically JSON **objects** with **primitive** properties including `string`, `number` (or `integer`), `boolean`, and `enum` fields.
|
||||
|
|
|
|||
150
tests/client/test_url_elicitation.py
Normal file
150
tests/client/test_url_elicitation.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
"""End-to-end tests for URL-mode elicitation (SEP-1036).
|
||||
|
||||
URL elicitation directs the user to an external URL for an out-of-band
|
||||
interaction (OAuth consent, API-key entry, payment) instead of answering a
|
||||
schema form, so sensitive data never enters the LLM context.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from mcp_types import ElicitRequestURLParams
|
||||
|
||||
from fastmcp import Context, FastMCP
|
||||
from fastmcp.client.client import Client
|
||||
from fastmcp.client.elicitation import ElicitResult
|
||||
from fastmcp.server.elicitation import (
|
||||
AcceptedUrlElicitation,
|
||||
CancelledElicitation,
|
||||
DeclinedElicitation,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def url_server():
|
||||
mcp = FastMCP("UrlElicitationServer")
|
||||
|
||||
@mcp.tool
|
||||
async def connect_account(context: Context) -> str:
|
||||
result = await context.elicit_url(
|
||||
message="Authorize access to your account",
|
||||
url="https://example.com/oauth/authorize",
|
||||
)
|
||||
if isinstance(result, AcceptedUrlElicitation):
|
||||
return "authorized"
|
||||
elif isinstance(result, DeclinedElicitation):
|
||||
return "declined"
|
||||
else:
|
||||
assert isinstance(result, CancelledElicitation)
|
||||
return "cancelled"
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
async def test_url_elicitation_accept(url_server):
|
||||
"""When the user consents to navigate, the tool proceeds."""
|
||||
|
||||
async def elicitation_handler(message, response_type, params, ctx):
|
||||
return ElicitResult(action="accept")
|
||||
|
||||
async with Client(url_server, elicitation_handler=elicitation_handler) as client:
|
||||
result = await client.call_tool("connect_account")
|
||||
assert result.data == "authorized"
|
||||
|
||||
|
||||
async def test_url_elicitation_decline(url_server):
|
||||
"""A declined URL elicitation returns DeclinedElicitation to the server."""
|
||||
|
||||
async def elicitation_handler(message, response_type, params, ctx):
|
||||
return ElicitResult(action="decline")
|
||||
|
||||
async with Client(url_server, elicitation_handler=elicitation_handler) as client:
|
||||
result = await client.call_tool("connect_account")
|
||||
assert result.data == "declined"
|
||||
|
||||
|
||||
async def test_url_elicitation_cancel(url_server):
|
||||
"""A cancelled URL elicitation returns CancelledElicitation to the server."""
|
||||
|
||||
async def elicitation_handler(message, response_type, params, ctx):
|
||||
return ElicitResult(action="cancel")
|
||||
|
||||
async with Client(url_server, elicitation_handler=elicitation_handler) as client:
|
||||
result = await client.call_tool("connect_account")
|
||||
assert result.data == "cancelled"
|
||||
|
||||
|
||||
async def test_url_elicitation_handler_receives_url_params(url_server):
|
||||
"""The handler receives ElicitRequestURLParams with the url and message.
|
||||
|
||||
For URL mode, `response_type` is None (there is no schema), and the params
|
||||
are an ElicitRequestURLParams carrying the url the user should visit.
|
||||
"""
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def elicitation_handler(message, response_type, params, ctx):
|
||||
captured["message"] = message
|
||||
captured["response_type"] = response_type
|
||||
captured["params"] = params
|
||||
return ElicitResult(action="accept")
|
||||
|
||||
async with Client(url_server, elicitation_handler=elicitation_handler) as client:
|
||||
await client.call_tool("connect_account")
|
||||
|
||||
assert captured["message"] == "Authorize access to your account"
|
||||
assert captured["response_type"] is None
|
||||
assert isinstance(captured["params"], ElicitRequestURLParams)
|
||||
assert captured["params"].url == "https://example.com/oauth/authorize"
|
||||
assert captured["params"].mode == "url"
|
||||
# An elicitation_id is auto-generated by the server.
|
||||
assert captured["params"].elicitation_id
|
||||
|
||||
|
||||
async def test_url_elicitation_handler_returning_none_accepts(url_server):
|
||||
"""Returning None (no explicit ElicitResult) is treated as acceptance."""
|
||||
|
||||
async def elicitation_handler(message, response_type, params, ctx):
|
||||
return None
|
||||
|
||||
async with Client(url_server, elicitation_handler=elicitation_handler) as client:
|
||||
result = await client.call_tool("connect_account")
|
||||
assert result.data == "authorized"
|
||||
|
||||
|
||||
async def test_url_elicitation_generates_unique_ids():
|
||||
"""Each elicit_url call auto-generates a distinct elicitation_id."""
|
||||
mcp = FastMCP("UrlElicitationServer")
|
||||
ids: list[str | None] = []
|
||||
|
||||
@mcp.tool
|
||||
async def connect_twice(context: Context) -> str:
|
||||
await context.elicit_url(message="first", url="https://example.com/a")
|
||||
await context.elicit_url(message="second", url="https://example.com/b")
|
||||
return "done"
|
||||
|
||||
async def elicitation_handler(message, response_type, params, ctx):
|
||||
ids.append(params.elicitation_id)
|
||||
return ElicitResult(action="accept")
|
||||
|
||||
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
|
||||
await client.call_tool("connect_twice")
|
||||
|
||||
assert len(ids) == 2
|
||||
assert ids[0] is not None
|
||||
assert ids[1] is not None
|
||||
assert ids[0] != ids[1]
|
||||
|
||||
|
||||
async def test_url_elicitation_not_supported_in_background_task():
|
||||
"""URL elicitation from a background task raises NotImplementedError.
|
||||
|
||||
The background-task elicitation relay only carries form-mode requests, so
|
||||
URL mode is explicitly unsupported there (rather than silently degrading).
|
||||
"""
|
||||
mcp = FastMCP("UrlElicitationServer")
|
||||
ctx = Context(mcp, task_id="task-123")
|
||||
|
||||
assert ctx.is_background_task
|
||||
|
||||
with pytest.raises(NotImplementedError, match="not yet supported from"):
|
||||
await ctx.elicit_url(message="hi", url="https://example.com")
|
||||
Loading…
Add table
Add a link
Reference in a new issue