Home Blog Contact
Home/Blog/How to Add Elicitation to an MCP Server for U…
How toLLM EngineeringMCPelicitationhuman-in-the-loop

How to Add Elicitation to an MCP Server for User Input

9 min readBy Miloš Mitrović

Some tools can't finish without one more fact from the person driving the session: which table to rebook, which branch to deploy to, whether it's really OK to overwrite the file. Elicitation is the Model Context Protocol feature that lets your server pause in the middle of a tool call, ask the user a structured question, and then continue with the answer. In the official SDKs you request it with a single call (ctx.elicit() in Python, server.server.elicitInput() in TypeScript) that sends an elicitation/create request and hands back the user's response. This guide wires it up end to end, including the schema rules that trip most people up.

Key Takeaways

  • To add elicitation, call ctx.elicit(message=..., schema=...) from inside a tool; the SDK sends an elicitation/create request and returns an object with action and data.
  • The client, not your server, must declare the elicitation capability at initialization, or the request fails.
  • Responses come back as one of three actions, accept, decline, or cancel, and you must handle all three distinctly.
  • The requestedSchema is a restricted subset of JSON Schema: a flat object of primitive properties only, no nested objects or arrays of objects.
  • The spec forbids requesting sensitive data (passwords, tokens, secrets) through elicitation.

What You Need

  • An existing MCP server. If you don't have one yet, start from a minimal FastMCP server and add elicitation to a tool inside it.
  • The official Python SDK (pip install "mcp[cli]") or the TypeScript SDK (npm install @modelcontextprotocol/sdk).
  • A host that supports elicitation. Elicitation entered the spec in the 2025-06-18 revision, so the client must implement it; test against a client that advertises the capability.

How Do You Add Elicitation to a Tool?

The whole procedure is one call plus a branch on the result. Here it is with the official Python SDK.

  1. Define the answer shape as a schema. Use a Pydantic model whose fields are all primitives. This becomes the requestedSchema the client renders as a form.
    from mcp.server.fastmcp import FastMCP, Context
    from pydantic import BaseModel, Field
    
    mcp = FastMCP("Booking")
    
    class BookingPreferences(BaseModel):
        checkAlternative: bool = Field(description="Try another date?")
        alternativeDate: str = Field(default="", description="Date as YYYY-MM-DD")
  2. Call ctx.elicit() inside the tool. Pass the prompt as message and the model as schema. Await it; execution pauses until the user answers.
    @mcp.tool()
    async def book_table(date: str, party_size: int, ctx: Context) -> str:
        if date != "2026-09-01":
            return f"Booked table for {party_size} on {date}"
    
        result = await ctx.elicit(
            message=f"No table for {party_size} on {date}. Pick another date?",
            schema=BookingPreferences,
        )
  3. Branch on result.action. Read the typed answer from result.data only when the action is accept.
        if result.action == "accept" and result.data:
            if result.data.checkAlternative:
                return f"Booked for {result.data.alternativeDate}"
            return "No booking made"
        if result.action == "decline":
            return "User declined an alternative date"
        return "Booking cancelled"
  4. Confirm the client declares the capability. Elicitation only works if the connected client advertised it during initialization. On the wire that is a single object:
    {
      "capabilities": {
        "elicitation": {}
      }
    }
  5. Run it against a real client and answer the prompt. When the tool hits the elicit call, the client shows a form built from your schema; submit it, and the tool resumes with your input.

That five-step loop is the entire feature. Everything below hardens it.

What Field Types Can the Schema Use?

The requestedSchema is deliberately narrow so any client can render it as a plain form. It must be a flat object, and each property is one of four primitive kinds. Nested objects, arrays of objects, and most advanced JSON Schema keywords are not supported by design.

TypeKeywordConstraints you can setUse it for
String"type": "string"minLength, maxLength, format (email, uri, date, date-time)Names, dates, free text
Number"type": "number" or "integer"minimum, maximumCounts, amounts, ranges
Boolean"type": "boolean"defaultYes/no confirmations
Enum"type": "string" + enumenum, optional enumNames for display labelsFixed choice lists

If you need a nested structure, split it across two elicitation calls or restructure the fields into a flat set. Trying to smuggle a nested object through will be dropped or rejected by a spec-compliant client.

How Should You Handle Accept, Decline, and Cancel?

The three-action model exists so you can tell "the user said no" apart from "the user walked away." Treating them as the same thing produces confusing behavior, like re-prompting someone who explicitly declined.

ActionWhat the user didcontent / data present?What your server should do
acceptFilled the form and submittedYes, matching the schemaValidate, then use the data
declineExplicitly said no (Reject, Decline)Usually omittedOffer an alternative or exit cleanly
cancelDismissed without choosing (Escape, closed dialog)Usually omittedLeave state untouched; you may prompt again later

How Is This Different in the TypeScript SDK?

The shape is identical; only the surface changes. The high-level McpServer exposes the underlying connection as .server, and you call its elicitInput method with a raw JSON requestedSchema. The answer arrives on result.content rather than result.data.

server.registerTool(
  "book_table",
  { title: "Book a table", inputSchema: { date: z.string(), partySize: z.number() } },
  async ({ date, partySize }) => {
    const result = await server.server.elicitInput({
      message: `No table for ${partySize} on ${date}. Pick another date?`,
      requestedSchema: {
        type: "object",
        properties: {
          alternativeDate: { type: "string", title: "Alternative date", format: "date" },
        },
        required: ["alternativeDate"],
      },
    });
    if (result.action === "accept") {
      return { content: [{ type: "text", text: `Booked for ${result.content.alternativeDate}` }] };
    }
    return { content: [{ type: "text", text: "No booking made" }] };
  },
);

One naming note worth knowing: the standalone FastMCP package documents the parameter as response_type rather than schema and lets you pass a bare scalar like str or a Literal for single-field prompts. If you installed fastmcp on its own instead of the bundled mcp package, use that signature.

What Should You Never Ask For?

Elicitation is for workflow input, not secrets. The specification states servers MUST NOT request sensitive information such as passwords or API tokens through it, and clients should rate-limit requests and make clear which server is asking. Keep the questions to things a person would happily type into a visible form: a date, a choice from a list, a confirmation. Route anything sensitive through a proper auth flow instead.

Elicitation is the user-facing half of a pair. Its sibling, sampling, lets the server ask the client's model for a completion rather than asking the human. Both are server-initiated callbacks that the client mediates, which is why the client, not the server, owns the capability declaration.

Troubleshooting

The Tool Hangs or the Request Fails Immediately

The connected client didn't declare the elicitation capability, so the request has nowhere to go. Guard the call: check the negotiated capabilities before invoking elicit, and fall back to a sensible default or a plain error message when the client can't support it.

You Get a "Method Not Found" (-32601) Error

Same root cause seen from the wire. The client answered elicitation/create with JSON-RPC error -32601 because it doesn't implement the method. Test against a client that advertises elicitation support.

A Nested Field Is Silently Missing

Your schema included a nested object or an array of objects, which the restricted schema forbids. Flatten the fields (for example, address_city and address_zip as separate string properties) or split the collection across multiple calls.

result.data Is None Even on Accept

The content the client returned didn't validate against your Pydantic model, usually a type mismatch (a string where the schema said number) or a missing required field. Confirm your model's field names and types match the requestedSchema exactly, and mark truly optional fields with defaults.

Declined Users Keep Getting Re-Prompted

You're treating decline and cancel as one branch. Handle them separately: decline means stop asking, cancel means the user stepped away and it's fine to ask again later.

What to Do Next

  • Read the full elicitation spec, including every schema keyword and security rule, in the MCP 2025-06-18 client specification.
  • Study the runnable server example in the official Python SDK repository to see form-mode elicitation in context.
  • If you're on the standalone package, follow the FastMCP elicitation guide for scalar and multi-select prompts.
  • Add the sibling capability so your server can also reason with the host model, following the MCP sampling guide linked above.

Sources

M
Miloš Mitrović
Email Marketing for Ecommerce

Have a question or a project?

Whether it is about this post or a system you want built, I'm happy to talk.

Get in touch

404

Post not found. It may have been moved or the link is incorrect.

← Back to the blog
Summarize with AI
ChatGPT, Perplexity, and Grok open with the prompt ready to run. Claude, Gemini, and Copilot open a chat with the prompt copied; press Ctrl+V (Cmd+V on Mac) to paste. The full text is included, so it works even without web access.