diff --git a/docs/apps/images/app-approval.png b/docs/apps/images/app-approval.png
new file mode 100644
index 000000000..162f4847f
Binary files /dev/null and b/docs/apps/images/app-approval.png differ
diff --git a/docs/apps/images/app-choice.png b/docs/apps/images/app-choice.png
new file mode 100644
index 000000000..178f6a2b0
Binary files /dev/null and b/docs/apps/images/app-choice.png differ
diff --git a/docs/apps/providers/approval.mdx b/docs/apps/providers/approval.mdx
new file mode 100644
index 000000000..15b683d15
--- /dev/null
+++ b/docs/apps/providers/approval.mdx
@@ -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'
+
+
+
+`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.
+
+
+
+
+
+```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
+```
+
+
+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.
+
+
+## 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.
diff --git a/docs/apps/providers/choice.mdx b/docs/apps/providers/choice.mdx
new file mode 100644
index 000000000..61f5dc3f5
--- /dev/null
+++ b/docs/apps/providers/choice.mdx
@@ -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'
+
+
+
+`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.
+
+
+
+
+
+```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
+```
+
+
+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.
+
+
+## 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.
diff --git a/docs/docs.json b/docs/docs.json
index 323517825..40988ad9e 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -213,6 +213,8 @@
"group": "Providers",
"icon": "layer-group",
"pages": [
+ "apps/providers/approval",
+ "apps/providers/choice",
"apps/providers/file-upload",
"apps/providers/generative"
],
diff --git a/examples/apps/approval/approval_server.py b/examples/apps/approval/approval_server.py
new file mode 100644
index 000000000..3c82da5bc
--- /dev/null
+++ b/examples/apps/approval/approval_server.py
@@ -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()
diff --git a/examples/apps/choice/choice_server.py b/examples/apps/choice/choice_server.py
new file mode 100644
index 000000000..b91dfb726
--- /dev/null
+++ b/examples/apps/choice/choice_server.py
@@ -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()
diff --git a/src/fastmcp/apps/approval.py b/src/fastmcp/apps/approval.py
new file mode 100644
index 000000000..17b124e1f
--- /dev/null
+++ b/src/fastmcp/apps/approval.py
@@ -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},
+ )
diff --git a/src/fastmcp/apps/choice.py b/src/fastmcp/apps/choice.py
new file mode 100644
index 000000000..aaffef903
--- /dev/null
+++ b/src/fastmcp/apps/choice.py
@@ -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},
+ )
diff --git a/tests/apps/test_approval.py b/tests/apps/test_approval.py
new file mode 100644
index 000000000..d06128612
--- /dev/null
+++ b/tests/apps/test_approval.py
@@ -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
diff --git a/tests/apps/test_choice.py b/tests/apps/test_choice.py
new file mode 100644
index 000000000..2de7b0a89
--- /dev/null
+++ b/tests/apps/test_choice.py
@@ -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