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. + + + The Approval provider shown in Goose, with a payment confirmation card and Approve/Cancel buttons + + +```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. + + + The Choice provider shown in Goose, with four lunch options as clickable buttons + + +```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