Tutorials
Ren Okabe10 min read4 views

MCP Elicitation in 2026: URL Mode, the Guard Pattern, and What Changed

MCP elicitation now has two modes, and the specification revision Google ranks first is superseded. What form mode allows, why URL mode is mandatory for credentials, why ctx.elicit() stopped working on 2026-07-28, and a runnable schema linter in Python and TypeScript.

Updated on August 24, 2026

A weathered stainless steel apartment intercom panel mounted on a pale grey wall, with rows of labelled name plates, call buttons and a central speaker grille, photographed in cool blue-grey light, August 2026
A weathered stainless steel apartment intercom panel mounted on a pale grey wall, with rows of labelled name plates, call buttons and a central speaker grille, photographed in cool blue-grey light, August 2026
On this page

Quick answer

As of August 2026, MCP elicitation is the mechanism that lets a server ask the user a question in the middle of a tool call, and it now has two modes. Form mode collects structured data through the client against a restricted JSON Schema. URL mode sends the user out of band to a URL and is mandatory for anything sensitive: the specification says servers MUST NOT ask for passwords, API keys, access tokens or payment credentials through form mode.

The part almost nothing on the open web has caught up with: the current specification revision is 2026-07-28, and it removed the server-initiated back channel that the original elicitation API depended on. On a modern connection a tool no longer calls ctx.elicit() and blocks. It asks for input by returning a request and letting the client call it again with the answer attached.

If you read one thing further, read the era table in the next section, because picking the wrong API for the negotiated protocol version is a hard error, not a graceful degradation.

Model Context Protocol Python TypeScript

The revision Google shows you is not the current one

Search for this topic and the first result is the 2025-11-25 elicitation specification. That revision is superseded. The current one is 2026-07-28, and on the day this was written it did not appear in the first page of results at all.

That matters more than a normal documentation lag, because elicitation has been rewritten twice and the second rewrite removed three protocol artifacts the first one introduced. Here is the whole history in one table, taken by fetching all three revisions and diffing them:

Scroll to see more

2025-06-182025-11-252026-07-28 (current)
Form modeyesyesyes
URL modeabsentintroducedretained
elicitationIdabsentrequired for URL moderemoved
notifications/elicitation/completeabsentpresentremoved
Error -32042 URLElicitationRequiredErrorabsentpresentremoved
InputRequiredResultabsentabsentintroduced
Capability declaredat initializationat initializationper request, in _meta
Server must be statefulyesyesno longer required

So an article written in late 2025 describes a protocol that is two rewrites old, and an article written in early 2026 describes machinery that no longer exists. Both read as authoritative, because both are accurate about the revision their author was looking at. Check the date on the specification URL before you trust anything you read about elicitation, including this page.

Form mode: the schema subset is narrower than you think

Form mode requests carry a requestedSchema, and the specification restricts it to flat objects with primitive properties only. Nested structures and arrays of objects are, in the spec's own words, intentionally not supported, so that clients can render a form without implementing a JSON Schema engine.

The allowed shapes are:

  • string, with optional minLength, maxLength, pattern, default, and format limited to exactly four values: email, uri, date, date-time
  • number or integer, with optional minimum, maximum, default
  • boolean, with optional default
  • a single-select enum, either a plain enum array of strings or a oneOf array where every entry carries both const and title
  • a multi-select enum, expressed as type: "array" whose items is either an enum of strings or an anyOf of const and title pairs

A minimal legal request looks like this:

json
{
  "method": "elicitation/create",
  "params": {
    "mode": "form",
    "message": "Please provide your contact information",
    "requestedSchema": {
      "type": "object",
      "properties": {
        "name":  { "type": "string", "description": "Your full name" },
        "email": { "type": "string", "format": "email" },
        "age":   { "type": "number", "minimum": 18 }
      },
      "required": ["name", "email"]
    }
  }
}

The failure mode nobody warns about is that a client is under no obligation to tell you your schema is out of subset. You send a nested object, the client renders something wrong or nothing at all, and you debug the wrong layer. A linter for exactly this is at the end of this article.

URL mode: a MUST, not a suggestion

URL mode was introduced in 2025-11-25 and survives into the current revision. Instead of a schema, the request carries a url and the client walks the user to it:

json
{
  "method": "elicitation/create",
  "params": {
    "mode": "url",
    "url": "https://mcp.example.com/ui/set_api_key",
    "message": "Please provide your API key to continue."
  }
}

Three things about this are easy to get wrong.

It is not how the client authorizes against your server. That is ordinary MCP authorization and it is a separate mechanism. URL mode is for when your server needs credentials or third party authorization on behalf of the user. The client's bearer token is untouched.

accept does not mean the interaction succeeded. For URL mode, action: "accept" means only that the user consented to open the URL. The work happens out of band and the client is not told the outcome. On 2026-07-28 the server learns the state when the client retries the original call and the server reads its own stored state or the echoed requestState.

The client is required to be paranoid on your behalf. Clients MUST NOT pre-fetch the URL or its metadata, MUST NOT open it without explicit consent, MUST show the full URL before consent, and MUST open it in a way that neither the client nor the model can inspect the page or the user's input. The specification gives a concrete platform example: on iOS, SFSafariViewController is acceptable and WKWebView is not.

Correspondingly, servers MUST NOT put anything sensitive in the URL and MUST NOT hand out a URL that is already authenticated to a protected resource, because a malicious client could replay it to impersonate the user.

The 2026-07-28 rewrite, and why ctx.elicit() stopped working

The current revision removed server-initiated requests from the wire entirely. A tool that is executing has no channel back to the user, so the classic pattern of pausing mid-function and awaiting an answer is not implementable.

What replaced it is a guard: the tool returns an InputRequiredResult describing what it needs, that round completes normally, and the client issues a fresh call with the answers attached. The tool runs again from the top, sees the answers, and either asks for the next thing or returns the real result.

The consequence worth internalising is that each round is a complete, independent request and response cycle. Nothing stays alive on the server between rounds. That is what makes multi-step elicitation work on stateless, serverless and load balanced deployments where two rounds are not guaranteed to reach the same worker. It is also why the current spec relaxed the statefulness requirement that 2025-11-25 imposed.

Two request-scoped properties carry the conversation:

  • ctx.input_responses, which is None on the first round and a mapping of the client's answers afterwards
  • ctx.request_state, a small opaque string you set on the way out and get back on the way in

Step by step: a guard-pattern tool

This is the shape of a tool that asks two questions across three rounds, using FastMCP on a 2026-07-28 connection.

Step 1. Write a helper that builds a single-field elicitation request.

python
from fastmcp import FastMCP, Context
from mcp.types import InputRequiredResult, ElicitRequest, ElicitRequestFormParams

mcp = FastMCP("Booking Server")


def ask(key: str, message: str, field: str, request_state: str | None = None):
    """Build an InputRequiredResult that elicits a single text field."""
    params = ElicitRequestFormParams(
        message=message,
        requested_schema={
            "type": "object",
            "properties": {field: {"type": "string"}},
            "required": [field],
        },
    )
    return InputRequiredResult(
        result_type="input_required",
        input_requests={key: ElicitRequest(method="elicitation/create", params=params)},
        request_state=request_state,
    )

Step 2. Write the tool as a guard. It re-runs from the top every round, so branch on what has already come back.

python
@mcp.tool
async def book_flight(ctx: Context) -> str | InputRequiredResult:
    responses = ctx.input_responses

    if responses is None:                       # round 1: nothing asked yet
        return ask("destination", "Where would you like to fly?", "destination")

    if "destination" in responses:              # round 2: carry it forward
        destination = responses["destination"].content["destination"]
        return ask(
            "date",
            f"When would you like to fly to {destination}?",
            "date",
            request_state=f"dest={destination}",
        )

    destination = ctx.request_state.split("=", 1)[1]   # round 3: finish
    date = responses["date"].content["date"]
    return f"Booked a flight to {destination} on {date}"

Step 3. Check action before you touch content. A decline is a normal answer delivered to your tool, not an exception, and content is absent on both decline and cancel.

python
answer = responses["ok"]
if answer.action != "accept":
    return "Cancelled."
return f"Proceeding with {answer.content['ok']}"

Notice that the destination survives round two only because it was stashed in request_state. A local variable would not have, because the function body starts over each time.

Serving both eras without guessing

On handshake era connections, which is anything at or below 2025-11-25, the original API is still the correct one:

python
from dataclasses import dataclass


@dataclass
class UserInfo:
    name: str
    age: int


@mcp.tool
async def collect_user_info(ctx: Context) -> str:
    result = await ctx.elicit(message="Please provide your information",
                              response_type=UserInfo)
    if result.action == "accept":
        return f"Hello {result.data.name}, you are {result.data.age} years old"
    if result.action == "decline":
        return "Information not provided"
    return "Operation cancelled"

The gate between the two is strict in both directions. Return a guard result on a handshake connection, or call ctx.elicit() on a modern one, and you get an explicit era error naming the mismatch rather than a confusing generic failure. If your server has to serve both, branch on ctx.request_context.protocol_version and keep two paths.

This is the same trade that shows up whenever a framework moves human review out of a blocking call. If you have worked through pausing a LangGraph agent for human review, the guard pattern will feel familiar: the state has to live somewhere that survives the pause, and the only question is whether the framework hides that from you or makes you carry it explicitly. MCP now makes you carry it explicitly.

The phishing attack the specification documents

URL mode hands out a URL, and a URL can be forwarded. The spec walks through the resulting account takeover in full, and it is worth reading before you ship one:

  1. A malicious user triggers an elicitation against an otherwise well behaved server.
  2. The server generates a third party authorization URL, acting as an OAuth client.
  3. The attacker's client shows the URL and asks for consent.
  4. Instead of clicking it, the attacker sends the link to a victim on the same server.
  5. The victim completes the authorization, believing they are connecting their own account.
  6. The server receives the callback and assumes it belongs to the attacker's session.
  7. The victim's third party tokens are now bound to the attacker's identity.

The mitigation is not optional: the server MUST verify the identity of whoever opens the URL, typically by using its own authorization server and a session cookie, before accepting anything that comes back. Related, and easy to skip: servers MUST NOT treat user-supplied identity claims as authoritative. A user typing "I am someone@example.com" into a form-mode field proves nothing.

A linter for your requestedSchema

Nothing in the toolchain checks whether your form-mode schema is inside the allowed subset, so here is a dependency-free linter that does. It encodes the rules listed earlier: object root, flat primitives, the four permitted string formats, const plus title on every titled enum entry, arrays only as multi-select enums, and every required name present in properties.

python
#!/usr/bin/env python3
"""Lint an MCP form-mode elicitation requestedSchema against the restricted
subset the specification allows (checked against MCP 2026-07-28).

Usage:  python3 lint_elicitation_schema.py schema.json
"""
import json
import sys

PRIMITIVES = {"string", "number", "integer", "boolean"}
STRING_FORMATS = {"email", "uri", "date", "date-time"}


def lint(schema):
    errors = []
    if schema.get("type") != "object":
        errors.append('root: "type" must be "object"')
    props = schema.get("properties")
    if not isinstance(props, dict) or not props:
        errors.append('root: "properties" must be a non-empty object')
        return errors
    for name, spec in props.items():
        errors.extend(lint_property(name, spec))
    for name in schema.get("required", []):
        if name not in props:
            errors.append('required: "%s" is not declared in properties' % name)
    return errors


def lint_property(name, spec):
    if not isinstance(spec, dict):
        return ['%s: property schema must be an object' % name]
    if "oneOf" in spec:
        return lint_titled_enum(name, spec["oneOf"], "oneOf")
    ptype = spec.get("type")
    if ptype == "object":
        return ['%s: nested objects are not supported, flatten this field' % name]
    if ptype == "array":
        return lint_multi_select(name, spec)
    if ptype not in PRIMITIVES:
        return ['%s: type "%s" is outside the allowed set %s'
                % (name, ptype, sorted(PRIMITIVES))]
    errors = []
    if ptype == "string":
        fmt = spec.get("format")
        if fmt is not None and fmt not in STRING_FORMATS:
            errors.append('%s: format "%s" is not one of %s'
                          % (name, fmt, sorted(STRING_FORMATS)))
    return errors


def lint_multi_select(name, spec):
    """Arrays are legal only as the multi-select enum form."""
    items = spec.get("items")
    if not isinstance(items, dict):
        return ['%s: array properties require an "items" object' % name]
    if "anyOf" in items:
        return lint_titled_enum(name, items["anyOf"], "items.anyOf")
    if items.get("type") == "string" and isinstance(items.get("enum"), list):
        return []
    return ['%s: arrays are only supported as multi-select enums '
            '(items.enum of strings, or items.anyOf of const/title pairs)' % name]


def lint_titled_enum(name, entries, where):
    if not isinstance(entries, list) or not entries:
        return ['%s: %s must be a non-empty array' % (name, where)]
    errors = []
    for index, entry in enumerate(entries):
        if not isinstance(entry, dict) or "const" not in entry or "title" not in entry:
            errors.append('%s: %s[%d] must carry both "const" and "title"'
                          % (name, where, index))
    return errors


def main():
    if len(sys.argv) != 2:
        print("usage: lint_elicitation_schema.py schema.json")
        return 2
    with open(sys.argv[1]) as handle:
        schema = json.load(handle)
    errors = lint(schema)
    if not errors:
        print("OK: schema is within the elicitation subset")
        return 0
    for error in errors:
        print("FAIL: %s" % error)
    return 1


if __name__ == "__main__":
    sys.exit(main())

Run it against a schema with the five mistakes people actually make and it reports all of them:

text
FAIL: address: nested objects are not supported, flatten this field
FAIL: website: format "hostname" is not one of ['date', 'date-time', 'email', 'uri']
FAIL: score: type "null" is outside the allowed set ['boolean', 'integer', 'number', 'string']
FAIL: picks: arrays are only supported as multi-select enums (items.enum of strings, or items.anyOf of const/title pairs)
FAIL: colour: oneOf[0] must carry both "const" and "title"
FAIL: required: "missingField" is not declared in properties

The same logic in TypeScript, if your server is on the Node side. Node 22.6 and later can run this file directly, no build step:

typescript
#!/usr/bin/env node
import { readFileSync } from "node:fs";

const PRIMITIVES = ["string", "number", "integer", "boolean"];
const STRING_FORMATS = ["email", "uri", "date", "date-time"];

interface Json { [key: string]: unknown }

const isObject = (value: unknown): value is Json =>
  typeof value === "object" && value !== null && !Array.isArray(value);

export function lint(schema: Json): string[] {
  const errors: string[] = [];
  if (schema.type !== "object") errors.push('root: "type" must be "object"');

  const props = schema.properties;
  if (!isObject(props) || Object.keys(props).length === 0) {
    errors.push('root: "properties" must be a non-empty object');
    return errors;
  }

  for (const [name, spec] of Object.entries(props)) {
    errors.push(...lintProperty(name, spec));
  }
  const required = Array.isArray(schema.required) ? schema.required : [];
  for (const name of required) {
    if (!(String(name) in props)) {
      errors.push(`required: "${name}" is not declared in properties`);
    }
  }
  return errors;
}

function lintProperty(name: string, spec: unknown): string[] {
  if (!isObject(spec)) return [`${name}: property schema must be an object`];
  if ("oneOf" in spec) return lintTitledEnum(name, spec.oneOf, "oneOf");

  const ptype = spec.type;
  if (ptype === "object") {
    return [`${name}: nested objects are not supported, flatten this field`];
  }
  if (ptype === "array") return lintMultiSelect(name, spec);
  if (typeof ptype !== "string" || !PRIMITIVES.includes(ptype)) {
    return [`${name}: type "${String(ptype)}" is outside the allowed set ${JSON.stringify(PRIMITIVES)}`];
  }

  const errors: string[] = [];
  if (ptype === "string" && spec.format !== undefined) {
    if (!STRING_FORMATS.includes(String(spec.format))) {
      errors.push(`${name}: format "${String(spec.format)}" is not one of ${JSON.stringify(STRING_FORMATS)}`);
    }
  }
  return errors;
}

/** Arrays are legal only as the multi-select enum form. */
function lintMultiSelect(name: string, spec: Json): string[] {
  const items = spec.items;
  if (!isObject(items)) return [`${name}: array properties require an "items" object`];
  if ("anyOf" in items) return lintTitledEnum(name, items.anyOf, "items.anyOf");
  if (items.type === "string" && Array.isArray(items.enum)) return [];
  return [
    `${name}: arrays are only supported as multi-select enums ` +
      `(items.enum of strings, or items.anyOf of const/title pairs)`,
  ];
}

function lintTitledEnum(name: string, entries: unknown, where: string): string[] {
  if (!Array.isArray(entries) || entries.length === 0) {
    return [`${name}: ${where} must be a non-empty array`];
  }
  const errors: string[] = [];
  entries.forEach((entry, index) => {
    if (!isObject(entry) || !("const" in entry) || !("title" in entry)) {
      errors.push(`${name}: ${where}[${index}] must carry both "const" and "title"`);
    }
  });
  return errors;
}

const file = process.argv[2];
if (file) {
  const errors = lint(JSON.parse(readFileSync(file, "utf8")));
  if (errors.length === 0) {
    console.log("OK: schema is within the elicitation subset");
  } else {
    for (const error of errors) console.log(`FAIL: ${error}`);
    process.exitCode = 1;
  }
}

Both implementations were run against the same pair of fixtures and produce identical output. If you are wiring elicitation into a server you built from scratch, the schema shapes here drop straight into the tool definitions from building an MCP server with FastMCP.

Limitations and open questions

This linter checks the specification, not your client. Passing it means your schema is inside the allowed subset. It does not mean every client renders it well. Client support for default pre-population, titled enums and multi-select is uneven, and the specification only says clients SHOULD pre-populate defaults.

URL mode is explicitly provisional. The specification carries a note on it saying its design and implementation may change in future protocol revisions. Given that 2026-07-28 already removed elicitationId, the completion notification and the -32042 error code, that warning has a track record behind it. Do not build a public integration on URL mode without a plan for the next revision.

The guard pattern moves a cost rather than removing it. Your tool no longer blocks, but every round re-runs the whole request path including your middleware chain, and anything expensive at the top of the tool now happens once per question. If your tool does an authorization lookup or a database read before it decides what to ask, that work is repeated on every round.

request_state is opaque and untrusted. It travels through the client. Treat it as a hint you must re-validate, not as a session, and keep anything security-relevant in server-side state keyed to a verified user identity.

Open question this article cannot answer. The specification says a form-mode field like a name, an email or a username is not categorically prohibited and that the decision is at the server's discretion. Where exactly the line sits between "profile information" and "a credential" is left to implementers, and it is the judgement call most likely to be made inconsistently across the ecosystem.

Ren Okabe

Written by

Ren Okabe

Ren builds agent infrastructure and writes copy-paste tutorials for engineers shipping LLM tool-use systems.

Frequently asked questions

What is elicitation in MCP?

Elicitation is the Model Context Protocol mechanism that lets a server ask the user for additional information during a tool call, rather than requiring every input upfront. As of August 2026 it supports two modes: form mode, which collects structured data through the client against a restricted JSON Schema, and URL mode, which sends the user out of band to a URL for interactions that must not pass through the client.

What is the difference between form mode and URL mode elicitation?

Form mode collects structured data in band. The request carries a requestedSchema and the answer comes back through the MCP client, so the data is exposed to the client. URL mode carries a url instead, and the interaction happens out of band in the user's browser, so nothing but the URL itself is exposed to the client. The specification makes URL mode mandatory for sensitive information: servers MUST NOT request passwords, API keys, access tokens or payment credentials through form mode.

Why does ctx.elicit() not work on MCP 2026-07-28?

The 2026-07-28 revision removed server-initiated requests from the wire, so a tool that is executing has no back channel to reach the user and cannot block waiting for an answer. Instead a tool returns an InputRequiredResult describing what it needs, that round completes normally, and the client issues a fresh call with the answer attached. FastMCP raises an explicit era error naming the mismatch if you call ctx.elicit() on a modern connection or return a guard result on a handshake connection.

Which JSON Schema types does an elicitation requestedSchema support?

Only flat objects with primitive properties. The permitted types are string, number, integer and boolean, plus single-select enums expressed as an enum array or a oneOf of const and title pairs, and multi-select enums expressed as an array whose items is an enum of strings or an anyOf of const and title pairs. String format is limited to exactly four values: email, uri, date and date-time. Nested objects and arrays of objects are intentionally not supported so that clients can render a form without a full JSON Schema engine.

What happens if the user declines an elicitation request?

Elicitation uses a three-action model that applies to both modes. accept means the user approved and, in form mode, submitted data in the content field. decline means the user explicitly refused. cancel means the user dismissed the prompt without choosing, for example by closing the dialog or pressing Escape. content is typically absent on both decline and cancel, so always check the action field before reading content. A decline is a normal answer delivered to your tool, not an error.

Does URL mode elicitation replace MCP authorization?

No. MCP authorization is the OAuth flow between the MCP client and the MCP server, and it is separate. URL mode elicitation is for when the server needs credentials or third-party authorization on behalf of the user, for example to call an external API. The client's bearer token is unchanged, third-party credentials MUST NOT transit through the client, and servers MUST NOT rely on URL mode elicitation to authorize users for themselves.

AI agents

Remote MCP Server Tutorial (2026): Serve Tools over Streamable HTTP

A runnable 2026 tutorial for turning a local MCP server into a remote one over Streamable HTTP. Serve tools with FastMCP, test the endpoint with curl and MCP Inspector, validate the Origin header, add a bearer token, then connect Claude. Covers the Mcp-Session-Id requirement and the DNS-rebinding gotcha the docs warn about but most walkthroughs skip.

9 min read200