Skip to content

Stonefish-Labs/mcp-elicit-fallback

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 

Repository files navigation

mcp-elicit-fallback

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.

The Problem

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.

Quick Start

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."

How It Works

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.

Verification

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]    |
                                          +------------------------+

Cancellation

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

Input Formats

Schema Dict (full control over form fields)

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"]

FastMCP response_type (native compatibility)

# 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"])

Mixed (different native vs browser rendering)

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)"},
    },
)

API Reference

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={...}).

elicit_via_browser(message, schema, timeout=120, on_ready=None)

Standalone browser form — no MCP context needed. Use for non-MCP workflows or testing.

ElicitResult

@dataclass
class ElicitResult:
    action: str                    # "accept" | "decline" | "cancel"
    data: dict[str, Any] = {}     # Form field values (coerced to declared types)

Security

  • 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.

Testing

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"]
    }
  }
}

Limitations

  • Headless/CI: No browser. Swap elicit_via_browser for a Slack/webhook backend returning the same ElicitResult.
  • 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.

When is the fallback still needed?

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 developmentforce_browser=True lets you exercise the browser path regardless of host support

Requirements

  • Python 3.10+
  • No external dependencies for the fallback itself
  • FastMCP 3.x for elicit_with_fallback (browser-only mode via elicit_via_browser needs nothing)

License

MIT

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages