Elicitation: asking the user for input mid-tool

Sometimes the tool — not the model — needs the user: confirming a destructive step, filling in required parameters the conversation never provided, or completing an external flow. MCP elicitation is a server→client request that pops a structured form (or a URL) in the host UI. nanohub-mcp supports both modes through the injected ctx.

Form mode

@server.tool(input_schema=..., output_schema=...)
def submit_batch(run_handle, ctx=None):
    """Submit the run to the cluster after user confirmation."""
    try:
        answer = ctx.elicit(
            "Submit this 4-hour batch run?",
            requested_schema={
                "type": "object",
                "properties": {
                    "queue": {"type": "string", "enum": ["standby", "long"],
                              "default": "standby"},
                    "notify": {"type": "boolean", "default": True},
                },
                "required": ["queue"],
            },
        )
    except RuntimeError:
        # Client declared no elicitation capability — degrade to conversation.
        return ToolResult(
            content="This client cannot show forms. Ask the user which queue "
                    "to use (standby or long), then call submit_batch_confirmed.",
            is_error=True)

    if answer.get("action") != "accept":
        return {"status": "cancelled_by_user"}
    queue = (answer.get("content") or {}).get("queue", "standby")
    ...

Facts that matter:

  • Handle all three actions. The result is {"action": "accept" | "decline" | "cancel", "content": {...}} — treat decline and cancel as first-class outcomes, not errors.

  • Capability-gated. The framework raises RuntimeError when the client didn’t declare the elicitation capability (form mode also accepts the bare {} declaration). Always wrap and provide a conversational fallback — many MCP clients still don’t implement it.

  • Keep the schema flat and small. Hosts render it as a form; nested objects and long enums make bad forms. Provide defaults — they prefill.

  • Default timeout is 60 s (ctx.elicit(..., timeout=…)); a user who walked away raises a timeout error you should catch and report calmly.

URL mode

result = ctx.elicit_url(
    "Authorize access to the external data source",
    url="https://example.org/authorize?state=...",
)

The host opens/offers the URL and reports completion (elicitationId correlates the flow). Use for third-party authorization or anything requiring a real browser context. Never use elicitation of either mode to collect passwords or secrets into tool results — results enter the model context.

Transport constraints (nanoHUB-specific)

  • Server→client requests ride the MCP session channel. They work through POST /mcp with an active session; the direct REST convenience route (POST /tools/{name}) refuses context-bearing tools with 409 for exactly this reason. If you add ctx to a tool, script users lose the REST shortcut for it — that is the intended trade.

  • Elicit before starting long work, from a fast sync tool (a confirm_and_submit step), not from inside an async task: the model and user may be several poll cycles away when a background thread asks.

When to elicit vs when to let the model ask

Situation

Use

destructive/expensive confirmation with options

ctx.elicit (form with enum + defaults)

a required parameter with a small closed choice set

elicit — beats a wrong guess

open-ended scientific intent (“what sweep range?”)

let the model ask in chat — it has context

external browser flow (OAuth to a data source)

ctx.elicit_url

clients without the capability

ToolResult telling the model what to ask