Elicitation
This guide explains how to use elicitation on the server side to request additional user input or external actions during tool execution.
What is Elicitation?
Elicitation allows a server tool to:
- Request structured user input (forms with schema validation)
- Ask the client to perform an external action (e.g. open a payment URL)
- Pause execution until the elicitation is accepted or rejected
Typical use cases:
- Collecting contact or configuration data
- User confirmation steps
- Payments or OAuth-style redirects
To use elicitation, inject Context into your tool handler and call the elicit() method with either form or URL elicit request params.
The Re-Run Model
Elicitation is the first-class MRTR input-request kind. Two consequences shape every handler that elicits:
elicittakes a stable replay key —ctx.elicit(key, params). The key is how the answer is matched back to this call site on the next round.- The handler re-runs from the top on every round. Code above an elicit
point executes again, so it must be side-effect-free — or wrapped in
ctx.memo(compute once),ctx.once(run once), orctx.on_commit(defer to the final result).
The client drives the rounds inside call_tool, so its caller still sees a
single call.
legacy-specElicitation is a capability-driven server→client push request instead:
ctx.elicit(params) takes no key, and the handler suspends rather than
re-running. See Legacy spec.
Ask Only for What the Caller Can Answer
Under MCP 2026-07-28 capabilities are declared per request, in the
call's _meta. Context::client_capabilities()
reports what the caller of this call declared, so a handler that can do
without an input can look before it asks:
use neva::{Context, error::Error, types::elicitation::ElicitRequestParams, tool};
#[tool]
async fn greet(mut ctx: Context) -> Result<String, Error> {
if ctx.client_capabilities().elicitation.is_none() {
return Ok("Hello, stranger!".to_string());
}
let params = ElicitRequestParams::form("Your name?")
.with_required("name", "string")
.into();
let res = ctx.elicit("name", params).await?;
Ok(format!("{:?}", res.content))
}
This is worth doing because asking anyway is not a degraded experience — it
ends the call with MissingRequiredClientCapability (-32021).
Elicitation is reported down to the mode
elicitation is not a flag but an
ElicitationModes:
the spec spells form and url as sub-capabilities inside the
elicitation object, and a client that can render a form may well be unable
to open a URL.
- A client that named modes is stating a list of what it can do — a mode missing from it is one it cannot answer.
- A client that declared
elicitationbut named no mode ({}) has ruled nothing out;unconstrained()istrueand every mode is allowed.
allows(¶ms) answers the whole question — "can this caller be sent
these params" — for either shape:
use neva::prelude::*;
use neva::types::elicitation::ElicitRequestParams;
let params: ElicitRequestParams = ElicitRequestParams::url(
"https://example.com/pay",
"Please pay your bill"
).into();
match ctx.client_capabilities().elicitation {
Some(modes) if modes.allows(¶ms) => { ctx.elicit("payment", params).await?; }
// Declared elicitation, but not this mode — take the other path.
_ => return Ok("Send an invoice instead".into()),
}
Defining a Form Elicitation
Forms use a JSON schema to define and validate structured input.
#[json_schema(de)]
struct Contact {
name: String,
email: String,
age: u32,
}
With #[json_schema] attribute macro you can control the serialization/deserialization performed by serde for your struct:
all- Applies alsoderive(serde::Serialize, serde::Deserialize).serde- Applies alsoderive(serde::Serialize, serde::Deserialize).ser- Applies alsoderive(serde::Serialize).de- Applies alsoderive(serde::Deserialize).
Creating and Sending a Form Request
To create elicit request form params you need to use the ElicitRequestParams::form() method with the following with_contract() that specifies the expected JSON schema.
#[tool]
async fn generate_business_card(mut ctx: Context) -> Result<String, Error> {
let params = ElicitRequestParams::form(
"Please provide your contact information"
)
.with_schema::<Contact>();
// "contact" is the replay key: it matches the client's answer
// back to this call site on the next round.
ctx.elicit("contact", params.into())
.await?
.map(format_contact)
}
fn format_contact(c: Contact) -> String {
format!("Name: {}, Age: {}, email: {}", c.name, c.age, c.email)
}
Execution flow:
- Server replies
input_required, carrying the form elicitation request - Client receives it, produces and validates the data, and retries the call
- The handler re-runs from the top;
ctx.elicit("contact", …)replays the answer - The result is mapped to the tool output
Guarding side effects
Anything expensive or externally visible above an elicit point needs a primitive, because that code runs again on every round:
#[tool]
async fn place_order(mut ctx: Context) -> Result<String, Error> {
// Computed once, replayed on later rounds.
let quote: u32 = ctx.memo("quote", async { Ok(fetch_quote().await) }).await?;
let params = ElicitRequestParams::form(format!("Shipping is ${quote}. Confirm?"))
.with_schema::<Contact>();
let contact: Contact = ctx.elicit("contact", params.into()).await?.content()
.ok_or_else(|| Error::new(ErrorCode::InvalidParams, "declined"))?;
// Runs at most once across all rounds.
ctx.once("charge", async { charge_card().await }).await?;
// Runs exactly once, when the handler reaches its final result.
ctx.on_commit(async { send_receipt().await });
Ok(format!("Order confirmed for {}", contact.name))
}
Defining a URL Elicitation
URL elicitations are used when the user must perform an external action. You can create the ElicitRequestUrlParams by leveraging the ElicitRequestParams::url() method.
#[tool]
async fn pay_a_bill(mut ctx: Context) -> Result<&'static str, Error> {
let params = ElicitRequestParams::url(
"https://www.paypal.com/us/webapps/mpp/paypal-payment",
"Please pay your bill using PayPal"
);
ctx.elicit("payment", params.into()).await?;
Ok("Payment successful")
}
MCP 2026-07-28 removed notifications/elicitation/complete — answering
the input request is the completion signal. Context::complete_elicitation,
Client::on_elicitation_completed, and ElicitationCompleteParams no longer
exist.
URL elicitation also lost its elicitationId: with no server-initiated
completion signal there is nothing to correlate. A server that needs to
track an elicitation across retries encodes its own identifier in
requestState — for example via ctx.memo.
- The client confirms acceptance; the answer is what resumes the tool
- Useful for payments, SSO, external confirmations
Learn By Example
A complete working example is available here.