Elicitation enables your MCP server tools to request additional information from users during execution. This creates interactive workflows where tools can dynamically gather structured data or direct users to external URLs for sensitive operations.
Overview
When a tool needs user input, it can use ctx.elicit() to send a request to the client. The client presents this request to the user and returns their response. This enables dynamic, interactive tool behavior that adapts based on user input.
Two Modes Available:
- Form Mode: Collect structured data with JSON schema validation (for non-sensitive information)
- URL Mode: Direct users to external URLs (MUST be used for sensitive data like credentials, OAuth)
Basic Usage
Use the elicit() method on the context object within tool callbacks. The API automatically detects the mode from your parameters:
import { MCPServer, text } from 'mcp-use/server';
import { z } from 'zod';
const server = new MCPServer({
name: 'my-server',
version: '1.0.0',
});
server.tool({
name: 'collect-user-info',
description: 'Collect user information',
}, async (params, ctx) => {
// Simplified API: ctx.elicit(message, zodSchema)
// Mode is automatically inferred from the Zod schema
const result = await ctx.elicit(
'Please provide your contact details',
z.object({
name: z.string().default('Anonymous'),
email: z.string().email(),
})
);
// result.data is automatically typed as { name: string, email: string }
if (result.action === 'accept')
return text(`Thank you, ${result.data.name}! We'll contact you at ${result.data.email}`);
else if (result.action === 'decline')
return text('No information provided');
else
return text('Operation cancelled');
});
Form mode allows you to collect structured data from users with Zod schema validation. Use this for non-sensitive information only.
const result = await ctx.elicit(message, zodSchema);
// Mode is automatically inferred as "form" from the Zod schema parameter
// result.data is automatically typed based on the Zod schema
Use Zod schemas to define the structure and validation rules for user input. Benefits:
- Type Safety: Return type is automatically inferred from your schema
- Server-Side Validation: Data is validated against the Zod schema before being returned (mcp-use feature)
- Validation: Built-in Zod validators (
.min(), .max(), .email(), etc.)
- Descriptions: Use
.describe() for field labels
- Defaults: Use
.default() for default values
- Optional fields: Use
.optional() for optional properties
Server-Side Validation
Automatic Validation with mcp-use: When using the mcp-use server library’s simplified API with Zod schemas (ctx.elicit(message, zodSchema)), the server automatically validates the returned data against your schema. This is a convenience feature provided by mcp-use. Invalid data is rejected with clear error messages before reaching your tool logic.
The validation flow:
- Schema Conversion: Zod schema is converted to JSON Schema and sent to the client
- User Input: Client collects data from the user
- Client Validation: Client validates against JSON Schema (optional)
- Data Return: Client returns data to server
- Server Validation: Server validates data against the original Zod schema
- Type Safety: Validated data is returned with correct TypeScript types
What Gets Validated
The server validates:
- Data Types: Strings, numbers, booleans must match schema
- Ranges: Min/max values for numbers
- String Constraints: MinLength, maxLength, patterns
- Formats: Email, URL, date formats
- Required Fields: Missing required fields are caught
- Enums: Values must be from allowed options
- Custom Validators: Any Zod refinements and transforms
Validation Error Handling
When validation fails, the error is caught and can be handled:
server.tool({
name: 'create-account',
description: 'Create account with validation'
}, async (params, ctx) => {
try {
const result = await ctx.elicit(
'Create your account',
z.object({
username: z.string().min(3).max(20),
email: z.string().email(),
age: z.number().min(18).max(120),
})
);
if (result.action === 'accept') {
// Data is guaranteed to be valid here
return text(`Account created for ${result.data.username}`);
}
return text('Account creation cancelled');
} catch (error) {
// Validation error or other failure
return error(`Validation failed: ${error.message}`);
}
});
Validation Examples
Valid Data:
// User provides: { username: "john_doe", email: "john@example.com", age: 25 }
// ✅ Passes validation - all fields valid
Invalid Age:
// User provides: { username: "john_doe", email: "john@example.com", age: 200 }
// ❌ Fails: "Too big: expected number to be <=120"
Invalid Email:
// User provides: { username: "john_doe", email: "not-an-email", age: 25 }
// ❌ Fails: "Invalid email address"
Missing Required Field:
// User provides: { username: "john_doe", age: 25 }
// ❌ Fails: "Invalid input: expected string, received undefined" (email missing)
Wrong Type:
// User provides: { username: "john_doe", email: "john@example.com", age: "twenty-five" }
// ❌ Fails: "Invalid input: expected number, received string"
Default Values
Fields with .default() are automatically filled if missing:
const result = await ctx.elicit(
'Enter preferences',
z.object({
theme: z.enum(['light', 'dark']).default('light'),
notifications: z.boolean().default(true),
})
);
// If user provides: {}
// result.data will be: { theme: 'light', notifications: true }
import { z } from 'zod';
import { text } from 'mcp-use/server';
server.tool({
name: 'register-user',
description: 'Register a new user',
}, async (params, ctx) => {
// Simplified API with Zod schema
const result = await ctx.elicit(
'Please complete your registration',
z.object({
name: z.string().min(2).describe('Your full name'),
email: z.string().email().describe('Your email address'),
age: z.number().min(18).max(120).describe('Your age'),
newsletter: z.boolean().default(false).describe('Subscribe to newsletter'),
})
);
// result.data is typed as:
// { name: string, email: string, age: number, newsletter: boolean }
if (result.action === 'accept') {
const user = result.data;
// TypeScript knows the exact shape of user data
return text(`Welcome, ${user.name}! Registration successful.`);
}
return text('Registration cancelled');
});
URL Mode
URL mode directs users to external URLs for sensitive operations. This mode MUST be used for:
- Authentication credentials
- API keys and tokens
- OAuth authorization flows
- Payment information
- Any sensitive personal data
Security Requirement: Never use form mode for sensitive data. URL mode ensures credentials and sensitive information do not pass through the MCP client, maintaining proper security boundaries per the MCP specification.
const result = await ctx.elicit(message, urlString);
// Mode is automatically inferred as "url" from the URL string parameter
// elicitationId is automatically generated
Example: OAuth Authorization
server.tool({
name: 'connect-github',
description: 'Authorize GitHub access',
}, async (params, ctx) => {
// Generate OAuth URL (in a real app, this would come from your OAuth provider)
const authUrl = `https://github.com/login/oauth/authorize?client_id=${GITHUB_CLIENT_ID}&state=${generateState()}`;
// Simplified API: ctx.elicit(message, url)
// Mode and elicitationId are handled automatically
const result = await ctx.elicit(
'Please authorize GitHub access to continue',
authUrl
);
if (result.action === 'accept') {
// User completed authorization
// In a real implementation, you would:
// 1. Receive the callback with authorization code
// 2. Exchange code for access token
// 3. Store tokens securely on the server
return text('✅ GitHub authorization successful! You can now use GitHub tools.');
} else if (result.action === 'decline') {
return error('❌ GitHub authorization declined. Some features will be unavailable.');
} else {
return error('Authorization cancelled');
}
});
Response Handling
The elicit() method returns an ElicitResult object:
interface ElicitResult {
action: 'accept' | 'decline' | 'cancel';
data?: any; // Only present for 'accept' action in form mode
}
Response Actions
| Action | Description | When to Use |
|---|
| accept | User provided valid input or completed the action | Check result.data for form mode input |
| decline | User explicitly declined the request | User chose not to provide information |
| cancel | User cancelled without making a choice | User dismissed or closed the request |
Always handle all three response actions appropriately:
server.tool({
name: 'example-tool',
description: 'Example showing response handling',
}, async (params, ctx) => {
const result = await ctx.elicit('Do you want to proceed?', z.object({ confirm: z.boolean().default(false) }));
// Handle all three cases
switch (result.action) {
case 'accept':
// User provided data
if (result.data.confirm) {
return text('✅ Proceeding with action');
}
return text('❌ Action not confirmed');
case 'decline':
// User explicitly declined
return text('🚫 User declined to provide confirmation');
case 'cancel':
// User cancelled
return text('⚠️ Operation cancelled by user');
default:
// Should never happen, but good practice
return error('❓ Unknown response');
}
});
Timeout Configuration
By default, elicit() waits indefinitely for user response (no timeout). You can optionally specify a timeout:
import { z } from 'zod';
server.tool({
name: 'time-sensitive-input',
description: 'Collect input with timeout',
}, async (params, ctx) => {
try {
// Wait indefinitely (default)
const result1 = await ctx.elicit(
'Please provide information',
z.object({ data: z.string() })
);
// With 60-second timeout
const result2 = await ctx.elicit(
'Quick! Provide information',
z.object({ data: z.string() }),
{ timeout: 60000 } // 1 minute timeout
);
// URL mode with timeout
const result3 = await ctx.elicit(
'Authorize (2 minute limit)',
'https://example.com/oauth',
{ timeout: 120000 } // 2 minute timeout
);
if (result2.action === 'accept') {
return text(`Received: ${result2.data.data}`);
}
return text('No response');
} catch (error) {
// Timeout or validation error
return error(`Request failed: ${error.message}`);
}
});
Default Behavior: Like ctx.sample(), elicitation has no timeout by default and waits indefinitely for user response. This prevents premature failures for users who may take time to complete forms or authorizations.
Error Handling
Wrap elicitation calls in try-catch blocks to handle errors gracefully:
server.tool({
name: 'safe-elicitation',
description: 'Tool with error handling',
}, async (params, ctx) => {
try {
const result = await ctx.elicit('Please provide information', z.object({ data: z.string() }));
if (result.action === 'accept') {
return text(`Received: ${result.data.data}`);
}
return text('No data provided');
} catch (error: unknown) {
// Client doesn't support elicitation or request failed
return error(`Error: ${error instanceof Error ? error.message : String(error)}. Please ensure your client supports elicitation.`);
}
});
Working Example
Check out the complete working example with form mode, URL mode, and conformance tests:
📁 examples/server/elicitation/
This example includes:
- Form mode with JSON schema validation
- URL mode for OAuth-like flows
- Conformance test tool
- Working test client
To run the example:
cd libraries/typescript/packages/mcp-use/examples/server/elicitation
pnpm install
pnpm dev
Server runs on http://localhost:3002 with MCP Inspector available.
Next Steps