Menu

AI

Lesson 18: Ask the User With Elicitation

Make an MCP tool pause and ask the user a question, the 2026-07-28 way, in Python and TypeScript, and handle accept, decline and cancel correctly.

By: Irina ShvayaOctober 28, 2026

Lesson 18: Ask the User With Elicitation

This lesson is part of Learn MCP, our free course on the Model Context Protocol, within The MCP Guide.

In this lesson you will make a tool stop and ask the user a question before it acts. You will add a confirmation step to a bookshop "order" tool in Python and TypeScript and handle all three answers. You will also learn why the 2026-07-28 spec changed how this works.

Elicitation is the MCP feature that lets a server request information from the user during a request. The elicitation spec defines two modes. Form mode asks for structured values the host renders as a form. URL mode sends the user to a web page for anything sensitive.

The code adapts the Python Elicitation guide and the TypeScript input_required guide. It ran on 28 October 2026 with mcp 2.3.0 and @modelcontextprotocol/server 2.4.0.

Key Takeaways

  • In the 2026-07-28 spec, a server no longer sends its own request to the client. It returns an InputRequiredResult, and the client retries the call with the answer.
  • In Python, a resolver (Resolve plus Elicit) asks for you and works for both new and old clients.
  • In TypeScript, return inputRequired(...) from the handler and read the answer with acceptedContent.
  • Every elicitation has three outcomes: accept, decline and cancel. Handle all three.
  • Never ask for passwords, API keys or payment details in a form. The spec requires URL mode for those.

Step 1: Understand what changed in 2026-07-28

Before the 2026-07-28 spec, a server asked a question by sending its own elicitation/create request back to the client in the middle of a tool call. The Python multi-round-trip guide says the new spec "retires that back-channel."

Now the flow is:

  1. The client calls the tool.
  2. The server answers with an InputRequiredResult that lists the questions it needs answered.
  3. The client asks the user, then calls the same tool again with the answers.
  4. The server finishes and returns a normal result.

Every step is a request from client to server. That fits the spec's stateless design, where any server instance can answer any request. Our spec reference on multi-round-trip requests covers the wire format.

Practical consequence: older SDK calls like Python's ctx.elicit() and TypeScript's ctx.mcpReq.elicitInput() only work on legacy connections. Both SDK docs say they fail on a 2026-07-28 connection.

Step 2: Add a confirmation in Python with a resolver

The Python SDK's recommended pattern is a resolver: a small function that supplies one tool parameter, and asks the user only when it must.

from typing import Annotated

from pydantic import BaseModel, Field

from mcp.server import MCPServer
from mcp.server.mcpserver import (
    AcceptedElicitation,
    CancelledElicitation,
    DeclinedElicitation,
    Elicit,
    ElicitationResult,
    Resolve,
)

mcp = MCPServer("Bookshop")


class ConfirmOrder(BaseModel):
    ok: bool = Field(description="Place the order?")
    gift_wrap: bool = Field(default=False, description="Add gift wrap?")


async def confirm_order(title: str, quantity: int) -> ConfirmOrder | Elicit[ConfirmOrder]:
    """Only ask when the order is large."""
    if quantity <= 2:
        return ConfirmOrder(ok=True)
    return Elicit(f"Order {quantity} copies of {title}?", ConfirmOrder)


@mcp.tool()
async def order_book(
    title: str,
    quantity: int,
    confirm: Annotated[ElicitationResult[ConfirmOrder], Resolve(confirm_order)],
) -> str:
    """Order copies of a book. Asks the user to confirm orders of more than 2 copies."""
    match confirm:
        case AcceptedElicitation(data=ConfirmOrder(ok=True, gift_wrap=wrap)):
            return f"Ordered {quantity} x {title}" + (" with gift wrap." if wrap else ".")
        case AcceptedElicitation():
            return "The user chose not to order."
        case DeclinedElicitation():
            return "The user declined. Nothing was ordered."
        case CancelledElicitation():
            return "The user dismissed the question. Nothing was ordered."

How it works, per the Elicitation guide:

  • confirm_order reads the tool's own title and quantity arguments by name.
  • Small orders resolve straight to ConfirmOrder(ok=True) with no question at all.
  • Large orders return Elicit(...), and the SDK asks the user.
  • The confirm parameter never appears in the tool's input schema. The model supplies title and quantity; the resolver supplies confirm.
  • ConfirmOrder is the form. Elicitation forms allow only flat, primitive fields: strings, numbers, booleans and string enums.

Step 3: Test both eras with a fake user

The Python Client answers elicitations through a callback. This test plays the user:

import anyio
from mcp import Client
from mcp.client import ClientRequestContext
from mcp.types import ElicitRequestParams, ElicitResult

from server import mcp


async def answer(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
    print("  asked:", params.message)
    return ElicitResult(action="accept", content={"ok": True, "gift_wrap": True})


async def main() -> None:
    for mode in ("auto", "legacy"):
        async with Client(mcp, mode=mode, elicitation_callback=answer) as client:
            print(mode, client.protocol_version)
            for qty in (1, 5):
                result = await client.call_tool("order_book", {"title": "Dune", "quantity": qty})
                print(" ", result.content[0].text)


anyio.run(main)

The output on 28 October 2026:

auto 2026-07-28
  Ordered 1 x Dune.
  asked: Order 5 copies of Dune?
  Ordered 5 x Dune with gift wrap.
legacy 2025-11-25
  Ordered 1 x Dune.
  asked: Order 5 copies of Dune?
  Ordered 5 x Dune with gift wrap.

The same tool code served both protocol eras. A second callback that returned ElicitResult(action="decline") produced "The user declined. Nothing was ordered." in both eras too.

Step 4: Add the same confirmation in TypeScript

In TypeScript, the handler returns inputRequired(...) while the answer is missing, and reads it on the retry:

import type { CallToolResult, InputRequiredResult } from '@modelcontextprotocol/server';
import { acceptedContent, inputRequired, inputResponse, McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';

const confirmSchema = z.object({
    ok: z.boolean().meta({ title: 'Place the order?' }),
    giftWrap: z.boolean().default(false).meta({ title: 'Add gift wrap?' })
});

function createServer(): McpServer {
    const server = new McpServer({ name: 'bookshop', version: '1.0.0' });

    server.registerTool(
        'order_book',
        {
            description: 'Order copies of a book. Asks the user to confirm orders of more than 2 copies.',
            inputSchema: z.object({ title: z.string(), quantity: z.number().int().min(1) })
        },
        async ({ title, quantity }, ctx): Promise<CallToolResult | InputRequiredResult> => {
            if (quantity <= 2) {
                return { content: [{ type: 'text', text: `Ordered ${quantity} x ${title}.` }] };
            }
            const view = inputResponse(ctx.mcpReq.inputResponses, 'confirm');
            if (view.kind === 'elicit' && view.action !== 'accept') {
                return { content: [{ type: 'text', text: `The user chose ${view.action}. Nothing was ordered.` }] };
            }
            const answer = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', confirmSchema);
            if (answer === undefined) {
                return inputRequired({
                    inputRequests: {
                        confirm: inputRequired.elicit({
                            message: `Order ${quantity} copies of ${title}?`,
                            requestedSchema: confirmSchema
                        })
                    }
                });
            }
            if (!answer.ok) return { content: [{ type: 'text', text: 'The user chose not to order.' }] };
            return {
                content: [{ type: 'text', text: `Ordered ${quantity} x ${title}${answer.giftWrap ? ' with gift wrap' : ''}.` }]
            };
        }
    );
    return server;
}

Points from the TypeScript guide:

  • acceptedContent returns undefined for a missing, declined or cancelled answer alike.
  • inputResponse tells those cases apart, so the handler can respect a "no" instead of asking again.
  • Answers come from the client, so treat them as untrusted. Passing the Zod schema to acceptedContent validates them.
  • The Zod schema is converted to the spec's restricted form schema. Nested objects and regex patterns throw a TypeError before anything is sent.

Driven by a client with an elicitation/create handler, this ran in the modern era on HTTP, and in both eras over stdio. One limit showed up: on a stateless HTTP endpoint, a legacy (2025-era) client could not be asked, and the tool returned an error saying so. The Python deploy guide describes the same trade-off for its stateless legacy mode.

Step 5: Follow the spec's safety rules

The elicitation spec sets firm rules. Servers:

  • must not use form mode to request sensitive information such as passwords, API keys or payment credentials;
  • must use URL mode for those interactions;
  • must handle the user declining or cancelling, or the client failing to process the request;
  • must bind elicitation requests to the client and user identity.

Our security risks guide explains why: form answers travel through the client and can land in the model's context.

Try it

  1. Change the threshold so orders over 10 copies need confirmation.
  2. Add a delivery field to the form as a string enum with "standard" and "express".
  3. Write a callback that returns action="cancel" and check the tool's reply.
  4. Add a fourth case: the client does not support elicitation at all. Decide what the tool should do, and test it by removing the callback.

With mcp 2.3.0, a client with no elicitation callback asked for 5 copies. The whole request failed with error -32021: "Client did not declare the form elicitation capability required by resolver..." The tool body never ran. The TypeScript guide documents the same -32021 code for a missing capability. Every confirmation needs a plan for "I can't ask."

Recap

  • 2026-07-28 elicitation returns a question from the call; the client retries with the answer.
  • Python: a Resolve parameter whose resolver returns Elicit(...) when it must ask.
  • TypeScript: return inputRequired(...), then read with inputResponse and acceptedContent.
  • Handle accept, decline and cancel, and never collect secrets in a form.

Next step

Lesson 19 turns the Inspector into a repeatable test harness. For the concept in depth, read MCP elicitation. If your server needs confirmations around payments or account changes, eSEOspace MCP development can help scope the flow.

Course navigation

Previous lesson: Lesson 17: Add Prompts.

Next lesson: Lesson 19: Test With the MCP Inspector.

Back to the Learn MCP course outline.

Frequently asked questions

When should a tool ask instead of failing?

Ask when a person must decide something the model should not: confirming a destructive action, choosing between close matches, or approving a cost. If the model could supply the value itself, make it a tool argument instead.

Can I ask for a credit card number with a form?

No. The spec says servers must not use form mode for sensitive information, including payment credentials. Use URL mode, which sends the user to a secure page, so the data never passes through the client or the model.

Does the server have to remember state between the two calls?

No. The client sends the answers back with the retried call. If you need to carry extra state, the 2026-07-28 spec provides an opaque requestState token. The Python SDK seals that token by default, and its deploy guide says multi-instance servers must share the sealing keys.

What if the user never answers?

Then the client never retries, and nothing happens. Design tools so that an unanswered question leaves no half-finished work.

Do all hosts support elicitation?

No. Support varies, and a client only receives questions if it declared the elicitation capability. Check your target hosts, and make sure each tool behaves sensibly when it cannot ask.

You Might Also like to Read

Get a FREE Website Audit

Enter your website and get a 20–30 page SEO, GEO, AEO & CRO audit within 24 hours. Completely free.

Don't have a site yet? Get in touch →

Ready to speak with a marketing expert?
Give us a call! (916) 866-7893

Websites Launched
1407
Websites Launched
Marketing Experts
29
Marketing Experts
Hours of Dedicated Work
204533
Hours of Dedicated Work
Irina S.Benjamin G.Jeanette M.David G.