Drop-in elicitation fallback for FastMCP servers. Tries native ctx.elicit() first, falls back to a localhost browser form when the host doesn't support it.
Single file. Zero external dependencies. Stdlib only.
MCP elicitation lets servers request structured human input during tool execution — approval forms, confirmation dialogs, parameter editing. Native support varies by host:
| Host | Native elicitation |
|---|---|
| Cursor | Yes |
| VS Code (Copilot Chat) | Yes |
| Claude Code 2.1.76+ | Yes (added March 2026) |
| Claude Code < 2.1.76 | No |
| Claude Code via Bedrock | May not work depending on configuration |
| Custom orchestrators | Varies |
If your tools need human approval before destructive actions, you need them to work everywhere — not just in hosts that support the protocol today.
This module wraps ctx.elicit() with automatic fallback: native when available, browser form when not. Same call, same result type, works in every host.
Copy fallback_elicit.py into your MCP server project. Then:
from fastmcp import FastMCP
from fastmcp.server.context import Context
from fastmcp.dependencies import CurrentContext
from fallback_elicit import elicit_with_fallback
server = FastMCP("my-server")
@server.tool()
async def dangerous_action(target: str, ctx: Context = CurrentContext()) -> str:
result = await elicit_with_fallback(
ctx,
message=f"Approve action on {target}?",
schema={
"target": {
"type": "string",
"description": "Target (review/edit)",
"default": target,
},
"confirm": {
"type": "boolean",
"description": "I confirm this action",
"default": False,
},
},
timeout=120,
)
if result.action == "accept" and result.data.get("confirm"):
return do_the_thing(result.data["target"])
return "Action not approved."Agent calls MCP tool
|
v
elicit_with_fallback(ctx, message, schema)
|
|-- 1. Try ctx.elicit() natively
| Works in Cursor, VS Code, Claude Code 2.1.76+
| If it succeeds -> return result, done
|
|-- 2. Native failed or unsupported -> browser fallback
| Generate verification code (e.g. BLUE-TIGER-42)
| Report code via MCP progress/logging -> appears in terminal
| Spin up localhost HTTP server on random port
| Open browser tab with approval form
| Form shows same verification code for correlation
| Block until user submits, declines, or timeout
| Shut down HTTP server, return result
From the host's perspective, the tool call is just slow — it blocks until the human responds. The host doesn't need to cooperate.
The user sees a matching code in both places:
Terminal Browser tab
-------- -----------
Tool: file_bug +------------------------+
Approval required [BLUE-TIGER-42] | Approval Required |
opening browser: http://... | BLUE-TIGER-42 |
| |
(codes match -> this is legit) | Confirm this code |
| matches your terminal |
| ...form fields... |
| [Approve] [Decline] |
+------------------------+
The browser form polls GET /status every 800ms:
| Event | What happens |
|---|---|
| User submits | Form auto-closes, result returned |
| User Ctrl+C / agent moves on | Form sees "cancelled", auto-closes |
| Timeout | Same as cancel |
| User closes browser tab | Server-side timeout fires |
result = await elicit_with_fallback(
ctx,
message="Confirm action",
schema={
"name": {"type": "string", "description": "Name", "default": ""},
"priority": {"type": "string", "enum": ["low", "high"], "default": "low"},
"confirm": {"type": "boolean", "description": "Confirm", "default": False},
"notes": {"type": "text", "description": "Notes", "default": ""},
"count": {"type": "integer", "description": "Count", "default": 10},
},
)| type | Renders as | Notes |
|---|---|---|
"string" |
Text input | Default |
"text" |
Textarea | Multi-line |
"boolean" |
Checkbox | |
"integer" |
Number input | |
"number" |
Number input | |
Any + enum |
Dropdown | "enum": ["a", "b"] |
# Pydantic BaseModel — auto-converted to form for browser fallback
class Config(BaseModel):
name: str = Field(description="Name")
enabled: bool = Field(default=True, description="Enable?")
result = await elicit_with_fallback(ctx, "Configure", response_type=Config)
# None — confirmation-only modal
result = await elicit_with_fallback(ctx, "Delete 50 records?", response_type=None)
# List of strings — single-select dropdown
result = await elicit_with_fallback(ctx, "Pick env", response_type=["staging", "prod"])result = await elicit_with_fallback(
ctx,
message="Configure resource",
response_type=MyModel, # Used for native elicitation (rich Pydantic form)
schema={ # Used for browser fallback (custom layout)
"tags": {"type": "string", "description": "Tags (comma-separated)"},
},
)elicit_with_fallback(ctx, message, schema=None, response_type=_SENTINEL, timeout=120, force_browser=False)
Main entry point. Use inside any FastMCP 3.x tool.
| Param | Type | Description |
|---|---|---|
ctx |
Context |
FastMCP Context object |
message |
str |
Prompt shown to the user |
schema |
dict |
Form field definitions (see table above) |
response_type |
Any |
FastMCP response_type (BaseModel, list, None) |
timeout |
int |
Seconds before auto-cancel (browser fallback) |
force_browser |
bool |
Skip native, always use browser form |
Returns ElicitResult(action="accept"|"decline"|"cancel", data={...}).
Standalone browser form — no MCP context needed. Use for non-MCP workflows or testing.
@dataclass
class ElicitResult:
action: str # "accept" | "decline" | "cancel"
data: dict[str, Any] = {} # Form field values (coerced to declared types)- Nonce: Each form gets
secrets.token_urlsafe(32). Submissions without matching nonce get 403. - Localhost only: Binds to
127.0.0.1, unreachable from network. - Ephemeral port: Random OS-assigned port per session.
- One-shot: Rejects submissions after the first valid one (409).
- Agent can't submit: The agent process has no access to the nonce, port, or browser. The approval channel is outside its trust domain.
- Verification code: Human-readable code shown in both terminal and browser for visual correlation.
test_server.py is a ready-to-use MCP server with four tools:
| Tool | Tests |
|---|---|
test_form |
Full form — text inputs, dropdowns, checkbox, number, textarea |
test_confirm |
Confirmation-only modal (response_type=None) |
test_dropdown |
Single-select dropdown (response_type=["red", "green", ...]) |
test_browser_fallback |
Forces browser path via force_browser=True |
{
"mcpServers": {
"elicit-test": {
"command": "python",
"args": ["/path/to/test_server.py"]
}
}
}- Headless/CI: No browser. Swap
elicit_via_browserfor a Slack/webhook backend returning the sameElicitResult. - Rich validation: The HTML form is minimal. Add JS validation if needed.
- Multiple approvers: One form, one submission, one user.
- Client timeouts: Some MCP hosts have short tool call timeouts (10-30s) that may cancel the fallback before the user can respond. Check your host's timeout config.
Claude Code 2.1.76 added native MCP elicitation support, which means the browser fallback won't trigger in most standard Claude Code setups. The fallback remains valuable for:
- Older Claude Code versions (pre-2.1.76) that don't support
ctx.elicit() - Bedrock-backed Claude Code where elicitation may fail due to configuration differences
- Custom MCP orchestrators or agents that haven't implemented the elicitation protocol
- Testing and development —
force_browser=Truelets you exercise the browser path regardless of host support
- Python 3.10+
- No external dependencies for the fallback itself
- FastMCP 3.x for
elicit_with_fallback(browser-only mode viaelicit_via_browserneeds nothing)
MIT