Add Approval and Choice providers (#3686)

This commit is contained in:
Jeremiah Lowin 2026-03-28 20:58:17 -04:00 committed by GitHub
commit beb35a4ed8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 625 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

View file

@ -0,0 +1,80 @@
---
title: Approval
sidebarTitle: Approval
description: Human-in-the-loop approval gates for agent actions
icon: shield-check
tag: NEW
---
import { VersionBadge } from '/snippets/version-badge.mdx'
<VersionBadge version="3.2.0" />
`Approval` adds a human-in-the-loop confirmation step to any server. The LLM presents what it's about to do, the user approves or rejects via buttons, and the decision flows back into the conversation as a message.
<Frame>
<img src="/apps/images/app-approval.png" alt="The Approval provider shown in Goose, with a payment confirmation card and Approve/Cancel buttons" />
</Frame>
```python
from fastmcp import FastMCP
from fastmcp.apps.approval import Approval
mcp = FastMCP("My Server")
mcp.add_provider(Approval())
```
This registers a single tool:
| Tool | Visibility | Purpose |
|------|-----------|---------|
| `request_approval` | Model | Shows an approval card, sends the user's decision back as a message |
The LLM calls `request_approval` with a summary (and optional details) whenever it's about to take a significant action. The user sees a card with Approve and Reject buttons. Clicking either sends a message back into the conversation via `SendMessage`, which triggers the LLM's next turn.
The message looks like it came from the user:
```
"Deploy v3.2 to production" — I selected: Approve
```
<Note>
Approval is an advisory gate, not an enforcement mechanism. The conversation isn't blocked while the card is open — the user can keep typing, and a determined LLM could proceed without waiting. Think of it as a strong UX signal that encourages confirmation, not a security boundary. For hard enforcement, implement approval logic server-side in your tool implementations.
</Note>
## Configuration
The constructor sets defaults; the LLM can override all of these per-call via tool arguments.
```python
Approval(
name="Approval", # App name
title="Approval Required", # Card heading
approve_text="Approve", # Approve button label
reject_text="Reject", # Reject button label
approve_variant="default", # "default", "destructive", "success", "info"
reject_variant="outline", # same options plus "outline"
)
```
The LLM can customize each invocation:
```python
request_approval(
summary="Delete 47 files from /tmp",
details="This cannot be undone.",
title="Destructive Action",
approve_text="Delete",
approve_variant="destructive",
reject_text="Keep files",
)
```
## How It Works
When the user clicks a button, two things happen:
1. `SendMessage` pushes the decision into the conversation as a user message
2. `SetState("decided", True)` replaces the buttons with "Response sent."
The tool description instructs the LLM to stop and wait for the "I selected:" message before proceeding. If approved, it continues. If rejected, it acknowledges and asks how to proceed.

View file

@ -0,0 +1,72 @@
---
title: Choice
sidebarTitle: Choice
description: Present clickable options instead of free-text responses
icon: list-check
tag: NEW
---
import { VersionBadge } from '/snippets/version-badge.mdx'
<VersionBadge version="3.2.0" />
`Choice` lets the LLM present a set of options as clickable buttons instead of asking the user to type a response. The selection flows back into the conversation as a message, giving the LLM clean structured input.
<Frame>
<img src="/apps/images/app-choice.png" alt="The Choice provider shown in Goose, with four lunch options as clickable buttons" />
</Frame>
```python
from fastmcp import FastMCP
from fastmcp.apps.choice import Choice
mcp = FastMCP("My Server")
mcp.add_provider(Choice())
```
This registers a single tool:
| Tool | Visibility | Purpose |
|------|-----------|---------|
| `choose` | Model | Shows a card with clickable options, sends the selection back as a message |
The LLM calls `choose` with a prompt and a list of options. The user sees a card with one button per option. Clicking one sends a message back into the conversation:
```
"Which deployment strategy?" — I selected: Blue-green
```
<Note>
Like [Approval](/apps/providers/approval), this is an advisory interaction — the conversation isn't blocked while the card is open. The tool description instructs the LLM to wait for the "I selected:" response before proceeding.
</Note>
## Configuration
The constructor sets defaults; the LLM can override `title` per-call.
```python
Choice(
name="Choice", # App name
title="Choose an Option", # Default card heading
variant="outline", # Button style for all options
)
```
The LLM provides the options per-call:
```python
choose(
prompt="What should we have for lunch?",
options=["Pizza", "Tacos", "Ramen", "Salad"],
title="The Important Questions",
)
```
## How It Works
Each option renders as a full-width button in a vertical stack. When the user clicks one:
1. `SendMessage` pushes the selection into the conversation as a user message
2. `SetState("decided", True)` replaces the buttons with "Response sent."
The tool description instructs the LLM to stop and wait for the "I selected:" message before proceeding with whatever the user chose.

View file

@ -213,6 +213,8 @@
"group": "Providers",
"icon": "layer-group",
"pages": [
"apps/providers/approval",
"apps/providers/choice",
"apps/providers/file-upload",
"apps/providers/generative"
],

View file

@ -0,0 +1,13 @@
"""Approval gate — require human sign-off before the agent acts.
Usage:
uv run python approval_server.py
"""
from fastmcp import FastMCP
from fastmcp.apps.approval import Approval
mcp = FastMCP("Approval Demo", providers=[Approval()])
if __name__ == "__main__":
mcp.run()

View file

@ -0,0 +1,13 @@
"""Multiple choice — let the user pick from options instead of typing.
Usage:
uv run python choice_server.py
"""
from fastmcp import FastMCP
from fastmcp.apps.choice import Choice
mcp = FastMCP("Choice Demo", providers=[Choice()])
if __name__ == "__main__":
mcp.run()

View file

@ -0,0 +1,198 @@
"""Approval — a Provider that adds human-in-the-loop approval to any server.
The LLM presents a summary of what it's about to do, and the user
approves or rejects via buttons. The result is sent back into the
conversation as a message, prompting the LLM's next turn.
Requires ``fastmcp[apps]`` (prefab-ui).
Usage::
from fastmcp import FastMCP
from fastmcp.apps.approval import Approval
mcp = FastMCP("My Server")
mcp.add_provider(Approval())
"""
from __future__ import annotations
from typing import Literal
try:
from prefab_ui.actions import SetState
from prefab_ui.actions.mcp import SendMessage
from prefab_ui.app import PrefabApp
from prefab_ui.components import (
H3,
Button,
Card,
CardContent,
CardFooter,
CardHeader,
Column,
Muted,
Row,
Text,
)
from prefab_ui.components.control_flow import If
from prefab_ui.rx import STATE
except ImportError as _exc:
raise ImportError(
"Approval requires prefab-ui. Install with: pip install 'fastmcp[apps]'"
) from _exc
from fastmcp.apps.app import FastMCPApp
class Approval(FastMCPApp):
"""A Provider that adds human-in-the-loop approval to a server.
The LLM calls the ``request_approval`` tool with a summary and
optional details. The user sees an approval card with Approve and
Reject buttons. Clicking either sends a message back into the
conversation (via ``SendMessage``), triggering the LLM's next turn.
The message appears as if the user sent it, so the LLM sees
something like ``'"Deploy v3.2 to production" is APPROVED'``.
Example::
from fastmcp import FastMCP
from fastmcp.apps.approval import Approval
mcp = FastMCP("My Server")
mcp.add_provider(Approval())
Customized::
Approval(
title="Deploy Gate",
approve_text="Ship it",
approve_variant="default",
reject_text="Abort",
reject_variant="destructive",
)
"""
def __init__(
self,
name: str = "Approval",
*,
title: str = "Approval Required",
approve_text: str = "Approve",
reject_text: str = "Reject",
approve_variant: Literal[
"default", "destructive", "success", "info"
] = "default",
reject_variant: Literal[
"default", "outline", "destructive", "success", "info"
] = "outline",
) -> None:
super().__init__(name)
self._title = title
self._approve_text = approve_text
self._reject_text = reject_text
self._approve_variant = approve_variant
self._reject_variant = reject_variant
self._register_tools()
def __repr__(self) -> str:
return f"Approval({self.name!r})"
def _register_tools(self) -> None:
provider = self
@self.ui()
def request_approval(
summary: str,
details: str | None = None,
title: str | None = None,
approve_text: str | None = None,
reject_text: str | None = None,
approve_variant: str | None = None,
reject_variant: str | None = None,
) -> PrefabApp:
"""Request human approval before proceeding with an action.
Call this tool proactively whenever you are about to take a
significant or irreversible action and want the user to
confirm first. Do NOT wait for the user to ask you to seek
approval use your judgment about when confirmation is
appropriate.
The user will see an approval card with the summary, optional
details, and Approve/Reject buttons. When they click a button,
their decision appears as a message in the conversation (as if
the user typed it), like:
"Deploy v3.2 to production" I selected: Approve
or:
"Deploy v3.2 to production" I selected: Reject
IMPORTANT: After calling this tool, you MUST stop and wait
for the user's response. Do not continue, do not take any
other actions, do not generate further output until you see
the "I selected:" message. If approved, continue with the
action. If rejected, acknowledge and ask how to proceed.
Args:
summary: Brief description of the action requiring approval
(shown prominently to the user).
details: Optional longer explanation, context, or
consequences of the action.
title: Heading for the approval card (default: "Approval Required").
approve_text: Label for the approve button (default: "Approve").
reject_text: Label for the reject button (default: "Reject").
approve_variant: Button style "default", "destructive",
"success", or "info".
reject_variant: Button style for the reject button
(same options plus "outline").
"""
_title = title or provider._title
_approve = approve_text or provider._approve_text
_reject = reject_text or provider._reject_text
_approve_v = approve_variant or provider._approve_variant
_reject_v = reject_variant or provider._reject_variant
approve_msg = f'"{summary}" — I selected: {_approve}'
reject_msg = f'"{summary}" — I selected: {_reject}'
with Card(css_class="max-w-lg mx-auto") as view:
with CardHeader():
H3(_title)
with CardContent(), Column(gap=3):
Text(summary, css_class="font-medium")
if details:
Muted(details)
with CardFooter():
with If(STATE.decided):
Muted("Response sent.")
with If(~STATE.decided): # noqa: SIM117
with Row(gap=2, css_class="w-full justify-end"):
Button(
_reject,
variant=_reject_v,
on_click=[
SendMessage(reject_msg),
SetState("decided", True),
],
)
Button(
_approve,
variant=_approve_v,
on_click=[
SendMessage(approve_msg),
SetState("decided", True),
],
)
return PrefabApp(
view=view,
state={"decided": False},
)

141
src/fastmcp/apps/choice.py Normal file
View file

@ -0,0 +1,141 @@
"""Choice — a Provider that lets the user pick from a set of options.
The LLM presents options, the user clicks one, and the selection
flows back into the conversation as a message.
Requires ``fastmcp[apps]`` (prefab-ui).
Usage::
from fastmcp import FastMCP
from fastmcp.apps.choice import Choice
mcp = FastMCP("My Server")
mcp.add_provider(Choice())
"""
from __future__ import annotations
from typing import Literal
try:
from prefab_ui.actions import SetState
from prefab_ui.actions.mcp import SendMessage
from prefab_ui.app import PrefabApp
from prefab_ui.components import (
H3,
Button,
Card,
CardContent,
CardFooter,
CardHeader,
Column,
Muted,
Text,
)
from prefab_ui.components.control_flow import If
from prefab_ui.rx import STATE
except ImportError as _exc:
raise ImportError(
"Choice requires prefab-ui. Install with: pip install 'fastmcp[apps]'"
) from _exc
from fastmcp.apps.app import FastMCPApp
class Choice(FastMCPApp):
"""A Provider that lets the user choose from a set of options.
The LLM calls ``choose`` with a prompt and a list of options.
The user sees a card with one button per option. Clicking a button
sends the selection back into the conversation via ``SendMessage``,
triggering the LLM's next turn.
Example::
from fastmcp import FastMCP
from fastmcp.apps.choice import Choice
mcp = FastMCP("My Server")
mcp.add_provider(Choice())
"""
def __init__(
self,
name: str = "Choice",
*,
title: str = "Choose an Option",
variant: Literal[
"default", "outline", "destructive", "success", "info"
] = "outline",
) -> None:
super().__init__(name)
self._title = title
self._variant = variant
self._register_tools()
def __repr__(self) -> str:
return f"Choice({self.name!r})"
def _register_tools(self) -> None:
provider = self
@self.ui()
def choose(
prompt: str,
options: list[str],
title: str | None = None,
) -> PrefabApp:
"""Present the user with a set of options to choose from.
Call this tool when you need the user to make a decision
between discrete alternatives. Use it proactively don't
ask the user to type their choice in chat when you can
present clean, clickable options instead.
The user will see a card with one button per option. When
they click one, their choice appears as a message in the
conversation (as if the user typed it), like:
"Which deployment strategy?" I selected: Blue-green
IMPORTANT: After calling this tool, you MUST stop and wait
for the user's response. Do not continue or take any other
actions until you see the "I selected:" message.
Args:
prompt: The question or decision to present to the user.
options: List of options the user can choose from.
title: Optional heading for the card.
"""
_title = title or provider._title
with Card(css_class="max-w-lg mx-auto") as view:
with CardHeader():
H3(_title)
with CardContent():
Text(prompt, css_class="font-medium")
with CardFooter():
with If(STATE.decided):
Muted("Response sent.")
with If(~STATE.decided): # noqa: SIM117
with Column(gap=2, css_class="w-full"):
for option in options:
Button(
option,
variant=provider._variant,
css_class="w-full justify-start",
on_click=[
SendMessage(
f'"{prompt}" — I selected: {option}'
),
SetState("decided", True),
],
)
return PrefabApp(
view=view,
state={"decided": False},
)

View file

@ -0,0 +1,56 @@
"""Tests for the Approval provider."""
from fastmcp import FastMCP
from fastmcp.apps.approval import Approval
class TestApprovalProvider:
async def test_request_approval_returns_structured_content(self):
server = FastMCP("test", providers=[Approval()])
result = await server.call_tool(
"request_approval",
{"summary": "Delete 47 files"},
)
assert result.structured_content is not None
async def test_request_approval_with_details(self):
server = FastMCP("test", providers=[Approval()])
result = await server.call_tool(
"request_approval",
{"summary": "Deploy to prod", "details": "Version 3.2.0"},
)
assert result.structured_content is not None
async def test_tool_visible_to_model(self):
server = FastMCP("test", providers=[Approval()])
tools = await server.list_tools()
tool_names = [t.name for t in tools]
assert "request_approval" in tool_names
async def test_custom_name(self):
server = FastMCP("test", providers=[Approval(name="Gate")])
tools = await server.list_tools()
tool_names = [t.name for t in tools]
assert "request_approval" in tool_names
async def test_custom_button_text(self):
server = FastMCP(
"test",
providers=[
Approval(
approve_text="Ship it",
reject_text="Nope",
title="Deploy Gate",
)
],
)
result = await server.call_tool(
"request_approval",
{"summary": "Deploy v3.2"},
)
assert result.structured_content is not None

50
tests/apps/test_choice.py Normal file
View file

@ -0,0 +1,50 @@
"""Tests for the Choice provider."""
from fastmcp import FastMCP
from fastmcp.apps.choice import Choice
class TestChoiceProvider:
async def test_choose_returns_structured_content(self):
server = FastMCP("test", providers=[Choice()])
result = await server.call_tool(
"choose",
{"prompt": "Pick one", "options": ["A", "B", "C"]},
)
assert result.structured_content is not None
async def test_tool_visible_to_model(self):
server = FastMCP("test", providers=[Choice()])
tools = await server.list_tools()
tool_names = [t.name for t in tools]
assert "choose" in tool_names
async def test_custom_name(self):
server = FastMCP("test", providers=[Choice(name="Picker")])
tools = await server.list_tools()
tool_names = [t.name for t in tools]
assert "choose" in tool_names
async def test_custom_title(self):
server = FastMCP("test", providers=[Choice(title="Select Strategy")])
result = await server.call_tool(
"choose",
{"prompt": "How?", "options": ["Fast", "Slow"]},
)
assert result.structured_content is not None
async def test_many_options(self):
server = FastMCP("test", providers=[Choice()])
result = await server.call_tool(
"choose",
{
"prompt": "Pick a color",
"options": ["Red", "Blue", "Green", "Yellow", "Purple"],
},
)
assert result.structured_content is not None